@postrun/react 1.0.0 → 1.2.0

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/dist/index.d.cts CHANGED
@@ -211,7 +211,7 @@ declare function useDeleteProfile(): _tanstack_react_query.UseMutationResult<{
211
211
  /** One account offered for selection — the element type of the accounts list. */
212
212
  type DiscoverableAccount = DiscoverableAccountList['data'][number];
213
213
  /** Why a connect attempt ended in `error` — actionable reasons for the host. */
214
- type ConnectErrorReason = 'popup_blocked' | 'auth_failed' | 'connection_not_found' | 'select_failed' | 'reauth_required';
214
+ type ConnectErrorReason = 'prepare_failed' | 'popup_blocked' | 'auth_failed' | 'connection_not_found' | 'select_failed' | 'reauth_required';
215
215
  /**
216
216
  * The single outcome of a connect attempt. `active` carries the activated
217
217
  * connection (so the host can call `onConnected`); `connected_pending` means the
@@ -338,8 +338,28 @@ interface UseConnectParams {
338
338
  profileId: string;
339
339
  /** The platform to connect (X, LinkedIn, Meta, …). */
340
340
  platform: ConnectablePlatform;
341
- /** Called once a connection is fully ACTIVE (an account is bound). */
341
+ /** Called once a connection is fully ACTIVE (an account is bound). The
342
+ * connections list is auto-refetched too, so you rarely need to act here. */
342
343
  onConnected?: (connection: Connection) => void;
344
+ /** Called when the connect SUCCEEDS — `active` OR `connected_pending` (the
345
+ * grant landed but the account binds out-of-band / via a slow webhook). Use
346
+ * this to close your UI and let the auto-refetched list show the result;
347
+ * `onConnected` additionally hands you the bound connection on `active`. */
348
+ onSuccess?: () => void;
349
+ /** Called when the attempt fails, with the typed reason. */
350
+ onError?: (reason: ConnectErrorReason) => void;
351
+ /** Called when the user closes the OAuth popup without finishing. The hook
352
+ * stays in the `cancelled` phase; call `reset()` to re-arm for another try. */
353
+ onCancelled?: () => void;
354
+ /**
355
+ * Pre-mint the Nango session on mount (default `true`). Keep it `true` for a
356
+ * dedicated "Connect X" button. Set it `false` for a MULTI-platform picker —
357
+ * then call `prepare()` on the platform button's `onPointerEnter`/`onFocus` so
358
+ * only the platform the user is about to click mints a session (not all of
359
+ * them on open). The popup still needs a pre-minted session, so prepare on
360
+ * intent, not on click.
361
+ */
362
+ prepareOnMount?: boolean;
343
363
  }
344
364
  /**
345
365
  * The connect flow's UI state. `connected_pending` is a TERMINAL success state —
@@ -373,10 +393,17 @@ interface UseConnectResult {
373
393
  /**
374
394
  * Start the OAuth flow. MUST be called directly in the user's click handler
375
395
  * (no `await` before it): it opens the OAuth popup synchronously, so the
376
- * browser keeps it inside the user gesture. A no-op until the session is ready
377
- * (state `preparing`) disable the button until `phase` is `idle`.
396
+ * browser keeps it inside the user gesture. If the session isn't ready yet
397
+ * (`phase` !== `idle`) it kicks `prepare()` and no-ops the popup, so the next
398
+ * click works — prepare on intent (hover/focus) to make the first click open.
378
399
  */
379
400
  start: () => void;
401
+ /**
402
+ * Mint the Nango session ahead of the click. Idempotent (a no-op if a session
403
+ * is already held or a mint is in flight). Only needed with
404
+ * `prepareOnMount: false` — call it on the button's `onPointerEnter`/`onFocus`.
405
+ */
406
+ prepare: () => void;
380
407
  /** When `phase` is `picking`, activate the connection with the chosen account. */
381
408
  select: (externalAccountId: string) => void;
382
409
  /** Return to a fresh, ready state (re-mints the session) — e.g. a "try again". */
@@ -397,7 +424,7 @@ interface UseConnectResult {
397
424
  * The hosted `/connect` page remains the fallback for callers NOT using this SDK
398
425
  * (a plain link to `hosted_connect_url`); this hook never redirects.
399
426
  */
400
- declare function useConnect({ profileId, platform, onConnected, }: UseConnectParams): UseConnectResult;
427
+ declare function useConnect({ profileId, platform, onConnected, onSuccess, onError, onCancelled, prepareOnMount, }: UseConnectParams): UseConnectResult;
401
428
  /**
402
429
  * List a profile's connected accounts. Pass a `filter` to narrow by `kind`
403
430
  * (`posting` = social, `ads`) or `status` — e.g. a composer fetches
@@ -496,10 +523,15 @@ interface ConnectRenderApi {
496
523
  state: ConnectState;
497
524
  /**
498
525
  * Begin connecting. Call this DIRECTLY from your button's `onClick` — it opens
499
- * the OAuth popup synchronously, so don't `await` anything before it. A no-op
500
- * until the session is ready (`state.phase === 'preparing'`).
526
+ * the OAuth popup synchronously, so don't `await` anything before it.
501
527
  */
502
528
  start: () => void;
529
+ /**
530
+ * Mint the session ahead of the click — only needed with
531
+ * `prepareOnMount={false}` (a multi-platform picker): call it on the button's
532
+ * `onPointerEnter`/`onFocus`.
533
+ */
534
+ prepare: () => void;
503
535
  /** When `state.phase === 'picking'`, activate with the chosen account id. */
504
536
  select: (externalAccountId: string) => void;
505
537
  /** Reset to a fresh, ready state (e.g. a "try again" after an error/cancel). */
@@ -512,6 +544,15 @@ interface ConnectProps {
512
544
  platform: ConnectablePlatform;
513
545
  /** Called once a connection is fully ACTIVE (an account is bound). */
514
546
  onConnected?: (connection: Connection) => void;
547
+ /** Called when the connect succeeds — `active` OR `connected_pending`. */
548
+ onSuccess?: () => void;
549
+ /** Called when the attempt fails, with the typed reason. */
550
+ onError?: (reason: ConnectErrorReason) => void;
551
+ /** Called when the user closes the OAuth popup without finishing. */
552
+ onCancelled?: () => void;
553
+ /** Pre-mint on mount (default `true`). Set `false` for a multi-platform picker
554
+ * and call `prepare()` on intent — see {@link UseConnectParams.prepareOnMount}. */
555
+ prepareOnMount?: boolean;
515
556
  /** Render your own button + picker + status from the flow state. */
516
557
  children: (api: ConnectRenderApi) => ReactNode;
517
558
  }
@@ -546,7 +587,7 @@ interface ConnectProps {
546
587
  * The trigger MUST call `start()` directly in the click (it opens the popup
547
588
  * synchronously). Mount `<Connect>` inside a `<PostrunProvider>`.
548
589
  */
549
- declare function Connect({ profileId, platform, onConnected, children, }: ConnectProps): ReactNode;
590
+ declare function Connect({ profileId, platform, onConnected, onSuccess, onError, onCancelled, prepareOnMount, children, }: ConnectProps): ReactNode;
550
591
 
551
592
  type MediaUploadStatus = 'idle' | 'uploading' | 'processing' | 'ready' | 'failed';
552
593
  interface MediaUploadOptions {
package/dist/index.d.ts CHANGED
@@ -211,7 +211,7 @@ declare function useDeleteProfile(): _tanstack_react_query.UseMutationResult<{
211
211
  /** One account offered for selection — the element type of the accounts list. */
212
212
  type DiscoverableAccount = DiscoverableAccountList['data'][number];
213
213
  /** Why a connect attempt ended in `error` — actionable reasons for the host. */
214
- type ConnectErrorReason = 'popup_blocked' | 'auth_failed' | 'connection_not_found' | 'select_failed' | 'reauth_required';
214
+ type ConnectErrorReason = 'prepare_failed' | 'popup_blocked' | 'auth_failed' | 'connection_not_found' | 'select_failed' | 'reauth_required';
215
215
  /**
216
216
  * The single outcome of a connect attempt. `active` carries the activated
217
217
  * connection (so the host can call `onConnected`); `connected_pending` means the
@@ -338,8 +338,28 @@ interface UseConnectParams {
338
338
  profileId: string;
339
339
  /** The platform to connect (X, LinkedIn, Meta, …). */
340
340
  platform: ConnectablePlatform;
341
- /** Called once a connection is fully ACTIVE (an account is bound). */
341
+ /** Called once a connection is fully ACTIVE (an account is bound). The
342
+ * connections list is auto-refetched too, so you rarely need to act here. */
342
343
  onConnected?: (connection: Connection) => void;
344
+ /** Called when the connect SUCCEEDS — `active` OR `connected_pending` (the
345
+ * grant landed but the account binds out-of-band / via a slow webhook). Use
346
+ * this to close your UI and let the auto-refetched list show the result;
347
+ * `onConnected` additionally hands you the bound connection on `active`. */
348
+ onSuccess?: () => void;
349
+ /** Called when the attempt fails, with the typed reason. */
350
+ onError?: (reason: ConnectErrorReason) => void;
351
+ /** Called when the user closes the OAuth popup without finishing. The hook
352
+ * stays in the `cancelled` phase; call `reset()` to re-arm for another try. */
353
+ onCancelled?: () => void;
354
+ /**
355
+ * Pre-mint the Nango session on mount (default `true`). Keep it `true` for a
356
+ * dedicated "Connect X" button. Set it `false` for a MULTI-platform picker —
357
+ * then call `prepare()` on the platform button's `onPointerEnter`/`onFocus` so
358
+ * only the platform the user is about to click mints a session (not all of
359
+ * them on open). The popup still needs a pre-minted session, so prepare on
360
+ * intent, not on click.
361
+ */
362
+ prepareOnMount?: boolean;
343
363
  }
344
364
  /**
345
365
  * The connect flow's UI state. `connected_pending` is a TERMINAL success state —
@@ -373,10 +393,17 @@ interface UseConnectResult {
373
393
  /**
374
394
  * Start the OAuth flow. MUST be called directly in the user's click handler
375
395
  * (no `await` before it): it opens the OAuth popup synchronously, so the
376
- * browser keeps it inside the user gesture. A no-op until the session is ready
377
- * (state `preparing`) disable the button until `phase` is `idle`.
396
+ * browser keeps it inside the user gesture. If the session isn't ready yet
397
+ * (`phase` !== `idle`) it kicks `prepare()` and no-ops the popup, so the next
398
+ * click works — prepare on intent (hover/focus) to make the first click open.
378
399
  */
379
400
  start: () => void;
401
+ /**
402
+ * Mint the Nango session ahead of the click. Idempotent (a no-op if a session
403
+ * is already held or a mint is in flight). Only needed with
404
+ * `prepareOnMount: false` — call it on the button's `onPointerEnter`/`onFocus`.
405
+ */
406
+ prepare: () => void;
380
407
  /** When `phase` is `picking`, activate the connection with the chosen account. */
381
408
  select: (externalAccountId: string) => void;
382
409
  /** Return to a fresh, ready state (re-mints the session) — e.g. a "try again". */
@@ -397,7 +424,7 @@ interface UseConnectResult {
397
424
  * The hosted `/connect` page remains the fallback for callers NOT using this SDK
398
425
  * (a plain link to `hosted_connect_url`); this hook never redirects.
399
426
  */
400
- declare function useConnect({ profileId, platform, onConnected, }: UseConnectParams): UseConnectResult;
427
+ declare function useConnect({ profileId, platform, onConnected, onSuccess, onError, onCancelled, prepareOnMount, }: UseConnectParams): UseConnectResult;
401
428
  /**
402
429
  * List a profile's connected accounts. Pass a `filter` to narrow by `kind`
403
430
  * (`posting` = social, `ads`) or `status` — e.g. a composer fetches
@@ -496,10 +523,15 @@ interface ConnectRenderApi {
496
523
  state: ConnectState;
497
524
  /**
498
525
  * Begin connecting. Call this DIRECTLY from your button's `onClick` — it opens
499
- * the OAuth popup synchronously, so don't `await` anything before it. A no-op
500
- * until the session is ready (`state.phase === 'preparing'`).
526
+ * the OAuth popup synchronously, so don't `await` anything before it.
501
527
  */
502
528
  start: () => void;
529
+ /**
530
+ * Mint the session ahead of the click — only needed with
531
+ * `prepareOnMount={false}` (a multi-platform picker): call it on the button's
532
+ * `onPointerEnter`/`onFocus`.
533
+ */
534
+ prepare: () => void;
503
535
  /** When `state.phase === 'picking'`, activate with the chosen account id. */
504
536
  select: (externalAccountId: string) => void;
505
537
  /** Reset to a fresh, ready state (e.g. a "try again" after an error/cancel). */
@@ -512,6 +544,15 @@ interface ConnectProps {
512
544
  platform: ConnectablePlatform;
513
545
  /** Called once a connection is fully ACTIVE (an account is bound). */
514
546
  onConnected?: (connection: Connection) => void;
547
+ /** Called when the connect succeeds — `active` OR `connected_pending`. */
548
+ onSuccess?: () => void;
549
+ /** Called when the attempt fails, with the typed reason. */
550
+ onError?: (reason: ConnectErrorReason) => void;
551
+ /** Called when the user closes the OAuth popup without finishing. */
552
+ onCancelled?: () => void;
553
+ /** Pre-mint on mount (default `true`). Set `false` for a multi-platform picker
554
+ * and call `prepare()` on intent — see {@link UseConnectParams.prepareOnMount}. */
555
+ prepareOnMount?: boolean;
515
556
  /** Render your own button + picker + status from the flow state. */
516
557
  children: (api: ConnectRenderApi) => ReactNode;
517
558
  }
@@ -546,7 +587,7 @@ interface ConnectProps {
546
587
  * The trigger MUST call `start()` directly in the click (it opens the popup
547
588
  * synchronously). Mount `<Connect>` inside a `<PostrunProvider>`.
548
589
  */
549
- declare function Connect({ profileId, platform, onConnected, children, }: ConnectProps): ReactNode;
590
+ declare function Connect({ profileId, platform, onConnected, onSuccess, onError, onCancelled, prepareOnMount, children, }: ConnectProps): ReactNode;
550
591
 
551
592
  type MediaUploadStatus = 'idle' | 'uploading' | 'processing' | 'ready' | 'failed';
552
593
  interface MediaUploadOptions {
package/dist/index.js CHANGED
@@ -303,19 +303,31 @@ var POLL_TIMEOUT_MS = 15e3;
303
303
  function useConnect({
304
304
  profileId,
305
305
  platform,
306
- onConnected
306
+ onConnected,
307
+ onSuccess,
308
+ onError,
309
+ onCancelled,
310
+ prepareOnMount = true
307
311
  }) {
308
- const { client } = usePostrun();
312
+ const { client, queryClient } = usePostrun();
309
313
  const [state, setState] = useState({ phase: "preparing" });
310
314
  const [remintNonce, setRemintNonce] = useState(0);
311
315
  const sessionRef = useRef(null);
312
316
  const pickRef = useRef(null);
313
317
  const inFlightRef = useRef(false);
314
318
  const flowGenRef = useRef(0);
319
+ const preparingRef = useRef(false);
320
+ const prepareGenRef = useRef(0);
315
321
  const onConnectedRef = useRef(onConnected);
322
+ const onSuccessRef = useRef(onSuccess);
323
+ const onErrorRef = useRef(onError);
324
+ const onCancelledRef = useRef(onCancelled);
316
325
  useEffect(() => {
317
326
  onConnectedRef.current = onConnected;
318
- }, [onConnected]);
327
+ onSuccessRef.current = onSuccess;
328
+ onErrorRef.current = onError;
329
+ onCancelledRef.current = onCancelled;
330
+ });
319
331
  const abandonFlow = useCallback(() => {
320
332
  flowGenRef.current += 1;
321
333
  inFlightRef.current = false;
@@ -323,12 +335,23 @@ function useConnect({
323
335
  pickRef.current = null;
324
336
  pick?.reject(new Error("connect flow abandoned"));
325
337
  }, []);
326
- useEffect(() => {
327
- let abandoned = false;
338
+ const prepare = useCallback(() => {
339
+ if (sessionRef.current || preparingRef.current) return;
340
+ preparingRef.current = true;
341
+ const gen = prepareGenRef.current;
328
342
  setState({ phase: "preparing" });
329
- sessionRef.current = null;
343
+ const failPrepare = () => {
344
+ preparingRef.current = false;
345
+ setState({ phase: "error", reason: "prepare_failed" });
346
+ onErrorRef.current?.("prepare_failed");
347
+ };
330
348
  connectionsConnect({ client, path: { id: profileId }, body: { platform } }).then(({ data }) => {
331
- if (abandoned || !data) return;
349
+ if (prepareGenRef.current !== gen) return;
350
+ if (!data) {
351
+ failPrepare();
352
+ return;
353
+ }
354
+ preparingRef.current = false;
332
355
  sessionRef.current = {
333
356
  token: data.connect_session_token,
334
357
  providerConfigKey: data.provider_config_key,
@@ -336,16 +359,28 @@ function useConnect({
336
359
  };
337
360
  setState({ phase: "idle" });
338
361
  }).catch(() => {
339
- if (!abandoned) setState({ phase: "error", reason: "auth_failed" });
362
+ if (prepareGenRef.current !== gen) return;
363
+ failPrepare();
340
364
  });
365
+ }, [client, profileId, platform]);
366
+ useEffect(() => {
367
+ prepareGenRef.current += 1;
368
+ preparingRef.current = false;
369
+ sessionRef.current = null;
370
+ setState({ phase: "preparing" });
371
+ if (prepareOnMount) prepare();
341
372
  return () => {
342
- abandoned = true;
373
+ prepareGenRef.current += 1;
343
374
  abandonFlow();
344
375
  };
345
- }, [client, profileId, platform, remintNonce, abandonFlow]);
376
+ }, [profileId, platform, remintNonce, prepareOnMount, prepare, abandonFlow]);
346
377
  const start = useCallback(() => {
347
378
  const session = sessionRef.current;
348
- if (!session || inFlightRef.current) return;
379
+ if (!session) {
380
+ prepare();
381
+ return;
382
+ }
383
+ if (inFlightRef.current) return;
349
384
  inFlightRef.current = true;
350
385
  const gen = flowGenRef.current;
351
386
  const isCurrent = () => flowGenRef.current === gen;
@@ -410,20 +445,26 @@ function useConnect({
410
445
  switch (outcome.status) {
411
446
  case "active":
412
447
  setState({ phase: "active", connection: outcome.connection });
448
+ void queryClient.invalidateQueries({ queryKey: connectionKeys.lists() });
413
449
  onConnectedRef.current?.(outcome.connection);
450
+ onSuccessRef.current?.();
414
451
  return;
415
452
  case "connected_pending":
416
453
  setState({ phase: "connected_pending" });
454
+ void queryClient.invalidateQueries({ queryKey: connectionKeys.lists() });
455
+ onSuccessRef.current?.();
417
456
  return;
418
457
  case "cancelled":
419
458
  setState({ phase: "cancelled" });
459
+ onCancelledRef.current?.();
420
460
  return;
421
461
  case "error":
422
462
  setState({ phase: "error", reason: outcome.reason });
463
+ onErrorRef.current?.(outcome.reason);
423
464
  return;
424
465
  }
425
466
  });
426
- }, [client, profileId]);
467
+ }, [client, profileId, prepare, queryClient]);
427
468
  const select = useCallback((externalAccountId) => {
428
469
  const pick = pickRef.current;
429
470
  if (!pick) return;
@@ -434,7 +475,7 @@ function useConnect({
434
475
  const reset = useCallback(() => {
435
476
  setRemintNonce((n) => n + 1);
436
477
  }, []);
437
- return { state, start, select, reset };
478
+ return { state, start, prepare, select, reset };
438
479
  }
439
480
  function useConnections(profileId, filter) {
440
481
  const { client, queryClient } = usePostrun();
@@ -506,9 +547,21 @@ function Connect({
506
547
  profileId,
507
548
  platform,
508
549
  onConnected,
550
+ onSuccess,
551
+ onError,
552
+ onCancelled,
553
+ prepareOnMount,
509
554
  children
510
555
  }) {
511
- const api = useConnect({ profileId, platform, onConnected });
556
+ const api = useConnect({
557
+ profileId,
558
+ platform,
559
+ onConnected,
560
+ onSuccess,
561
+ onError,
562
+ onCancelled,
563
+ prepareOnMount
564
+ });
512
565
  return children(api);
513
566
  }
514
567
  var UploadError = class extends Error {