All files / confetti/src useConfetti.ts

100% Statements 33/33
92.3% Branches 12/13
100% Functions 9/9
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156                                2x         1x                                                                                                                                                                   28x 28x 28x 28x   28x     15x 7x 7x 7x 7x 7x   15x 9x 9x   9x           28x   9x     28x   1x     28x 1x   28x 1x       28x 9x 9x 5x 1x     28x    
/**
 * `useConfetti` — component-scoped confetti on a consumer-owned canvas.
 */
import { useCallback, useRef, useState, type RefCallback } from "react";
import { useLatest } from "@usefy/use-latest";
import { usePageVisibility } from "@usefy/use-page-visibility";
import { createConfettiEngine } from "./engine/createEngine";
import type {
  ConfettiEngine,
  ConfettiEngineOptions,
  EmitHandle,
  EmitOptions,
  FireOptions,
} from "./types";
 
/** Handle returned when no engine exists (canvas not attached yet). */
const NOOP_EMIT_HANDLE: EmitHandle = {
  stop() {
    /* no-op */
  },
  get active() {
    return false;
  },
};
 
/**
 * Value returned by {@link useConfetti}.
 *
 * All functions are referentially stable for the component's lifetime.
 * `isActive` re-renders the component **only** on idle↔active edges of the
 * engine — never per frame.
 *
 * @example
 * ```tsx
 * const { canvasRef, fire, isActive } = useConfetti();
 * return (
 *   <div style={{ position: "relative" }}>
 *     <canvas ref={canvasRef} style={{ position: "absolute", inset: 0 }} />
 *     <button onClick={() => fire()} disabled={isActive}>Celebrate</button>
 *   </div>
 * );
 * ```
 */
export interface UseConfettiReturn {
  /**
   * Attach this to the `<canvas>` you own. The engine is created lazily on
   * first attach and destroyed on detach/unmount.
   */
  canvasRef: RefCallback<HTMLCanvasElement>;
  /**
   * Fire one burst. Resolves when that burst's particles have all died.
   * Resolves immediately when no canvas is attached yet.
   */
  fire(opts?: FireOptions): Promise<void>;
  /**
   * Start a continuous emitter. Returns an inert handle when no canvas is
   * attached yet.
   */
  emit(opts?: EmitOptions): EmitHandle;
  /** Stop all emitters (live particles finish naturally). */
  stop(): void;
  /** Kill everything and wipe the canvas now. */
  clear(): void;
  /** `true` while particles are alive or an emitter is active. */
  isActive: boolean;
}
 
/**
 * React hook for confetti on a canvas **you** render. The engine is created
 * lazily when the canvas attaches, destroyed when it detaches or the
 * component unmounts, paused automatically while the page is hidden, and
 * respects `prefers-reduced-motion` by default (see
 * {@link ConfettiEngineOptions.reducedMotion}).
 *
 * `options` are read once, at engine creation (first canvas attach) — pass
 * a new canvas (or remount) to apply different options.
 *
 * StrictMode-safe: the dev double-invocation attaches → detaches → attaches,
 * leaving exactly one live engine and zero orphaned rAF/observers.
 *
 * @example
 * ```tsx
 * import { useConfetti } from "@usefy/confetti";
 *
 * function Celebration() {
 *   const { canvasRef, fire, isActive } = useConfetti();
 *   return (
 *     <div style={{ position: "relative", height: 240 }}>
 *       <canvas
 *         ref={canvasRef}
 *         style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
 *       />
 *       <button onClick={() => fire({ origin: { y: 0.9 } })} disabled={isActive}>
 *         🎉 Celebrate
 *       </button>
 *     </div>
 *   );
 * }
 * ```
 */
export function useConfetti(
  options: ConfettiEngineOptions = {},
): UseConfettiReturn {
  const engineRef = useRef<ConfettiEngine | null>(null);
  const unsubscribeRef = useRef<(() => void) | null>(null);
  const optionsRef = useLatest(options);
  const [isActive, setIsActive] = useState(false);
 
  const canvasRef = useCallback<RefCallback<HTMLCanvasElement>>(
    (canvas) => {
      // Detach: tear the previous engine down completely.
      if (engineRef.current) {
        unsubscribeRef.current?.();
        unsubscribeRef.current = null;
        engineRef.current.destroy();
        engineRef.current = null;
        setIsActive(false);
      }
      if (canvas) {
        const engine = createConfettiEngine(canvas, optionsRef.current);
        engineRef.current = engine;
        // Edge-only subscription — this is the ONLY re-render source.
        unsubscribeRef.current = engine.onActiveChange(setIsActive);
      }
    },
    [optionsRef],
  );
 
  const fire = useCallback(
    (opts?: FireOptions): Promise<void> =>
      engineRef.current ? engineRef.current.fire(opts) : Promise.resolve(),
    [],
  );
  const emit = useCallback(
    (opts?: EmitOptions): EmitHandle =>
      engineRef.current ? engineRef.current.emit(opts) : NOOP_EMIT_HANDLE,
    [],
  );
  const stop = useCallback((): void => {
    engineRef.current?.stop();
  }, []);
  const clear = useCallback((): void => {
    engineRef.current?.clear();
  }, []);
 
  // Pause while the tab is hidden — no burst "wall" on return (SPEC §2.1).
  usePageVisibility((visible) => {
    const engine = engineRef.current;
    if (!engine) return;
    if (visible) engine.resume();
    else engine.pause();
  });
 
  return { canvasRef, fire, emit, stop, clear, isActive };
}