import { given } from "@nivinjoseph/n-defensive"; import { Duration } from "./duration.js"; import { Delay } from "./delay.js"; import { ApplicationException } from "@nivinjoseph/n-exception"; import { DecoratorReplacementMethod, DecoratorTargetMethod, MethodDecoratorContext } from "./decorator-helpers.js"; /** * Creates a debounce decorator that ensures a method is only called after a specified delay * has passed since the last call. Useful for handling rapid-fire events like search input. * * @param delay - The duration to wait after the last call before executing * @returns A method decorator that implements debounce behavior * @throws ArgumentException if delay is not a positive Duration * * @example * ```typescript * class Example { * @debounce(Duration.fromSeconds(1)) * async handleInput(value: string): Promise { * // This will only execute after 1 second of no calls * console.log(`Processing: ${value}`); * } * } * ``` */ export function debounce< This, Args extends Array, Return extends Promise | void>( delay: Duration ): DebounceMethodDecorator; export function debounce< This, Args extends Array, Return extends Promise | void >( target: DecoratorTargetMethod, context: MethodDecoratorContext ): DecoratorReplacementMethod; export function debounce< This, Args extends Array, Return extends Promise | void>( delayOrTarget: Duration | DecoratorTargetMethod, context?: MethodDecoratorContext ): DebounceMethodDecorator | DecoratorReplacementMethod { if (delayOrTarget instanceof Duration) { const delay = delayOrTarget; given(delay, "delay").ensureIsObject().ensureIsInstanceOf(Duration) .ensure(t => t.toMilliSeconds() > 0, "delay should be greater than 0ms"); const decorator: DebounceMethodDecorator = function (target, context) { return createReplacementMethod(target, context, delay); }; return decorator; } const target = delayOrTarget; if (context == null) throw new ApplicationException("Context should not be null or undefined"); return createReplacementMethod(target, context, null); } function createReplacementMethod< This, Args extends Array, Return extends Promise | void >( target: DecoratorTargetMethod, context: MethodDecoratorContext, delay: Duration | null ): DecoratorReplacementMethod { const { name, kind } = context; given(kind, "kind").ensureHasValue().ensureIsString().ensure(t => t === "method", "debounce decorator can only be used on a method"); const activeKey = Symbol.for(`@nivinjoseph/n-util/debounce/${String(name)}/isActive`); const scheduledCallKey = Symbol.for(`@nivinjoseph/n-util/debounce/${String(name)}/scheduledCall`); // Mirror the initialization pattern used by throttle/dedupe: explicitly set // the backing fields on each instance rather than relying on the // `undefined`-is-falsy coincidence. Makes a future tightening of the // in-loop checks (e.g. `=== null` instead of `!= null`) safe. context.addInitializer(function (this) { (this as any)[activeKey] = false; (this as any)[scheduledCallKey] = null; }); return async function (this: This, ...args: Args) { (this as any)[scheduledCallKey] = async (): Promise => { await target.call(this, ...args); }; if ((this as any)[activeKey]) return; while ((this as any)[scheduledCallKey] != null && !(this as any)[activeKey]) { (this as any)[activeKey] = true; if (delay != null) await Delay.milliseconds(delay.toMilliSeconds()); const currentCall: () => Promise = (this as any)[scheduledCallKey]; (this as any)[scheduledCallKey] = null; try { await currentCall(); } finally { (this as any)[activeKey] = false; } } }; } export type DebounceMethodDecorator< This, Args extends Array, Return extends Promise | void > = ( target: DecoratorTargetMethod, context: MethodDecoratorContext ) => DecoratorReplacementMethod;