All files / modules callback.ts

100% Statements 80/80
94.11% Branches 16/17
100% Functions 15/15
100% Lines 74/74

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 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280        94x 94x   94x   94x                                       94x       5138x 5138x   5138x   4897x 4897x 93x     241x     5045x   167x   5045x   2618x 2618x         5045x 241x     5045x                           94x           94x                 94x                                                                                                                               94x             5059x       5059x 5059x   5059x   5059x   5059x 311x 302x 302x   302x 373x     302x       5059x   5059x 2643x 2643x 2643x 10x 10x     2633x       2630x 2630x   6x     2628x 2x     2628x           5059x                             94x   94x 94x   94x     2241x 2241x   2630x 2630x     2628x       94x   302x 302x   136x 10x 3x       136x         2633x 2633x 1129x 1129x     2633x 2633x 2241x          
/**
 * @module Modules/Callback
 */
 
import * as MC from "@lisn/globals/minification-constants";
import * as MH from "@lisn/globals/minification-helpers";
 
import { getDebouncedHandler } from "@lisn/utils/tasks";
 
import debug from "@lisn/debug/debug";
 
/**
 * @typeParam Args See {@link Callback}
 */
export type CallbackHandler<Args extends unknown[] = []> = (
  ...args: Args
) => CallbackReturnType | Promise<CallbackReturnType>;
 
export type CallbackReturnType =
  | typeof Callback.KEEP
  | typeof Callback.REMOVE
  | void;
 
/**
 * For minification optimization. Exposed through Callback.wrap.
 *
 * @ignore
 * @internal
 */
export const wrapCallback = <Args extends unknown[] = []>(
  handlerOrCallback: CallbackHandler<Args> | Callback<Args>,
  debounceWindow = 0,
): Callback<Args> => {
  const isFunction = MH.isFunction(handlerOrCallback);
  let isRemoved = () => false;
 
  if (isFunction) {
    // check if it's an invoke method
    const callback = callablesMap.get(handlerOrCallback);
    if (callback) {
      return wrapCallback(callback);
    }
  } else {
    isRemoved = handlerOrCallback.isRemoved;
  }
 
  const handler: CallbackHandler<Args> = isFunction
    ? handlerOrCallback
    : (...args: Args) => handlerOrCallback.invoke(...args);
 
  const wrapper = new Callback<Args>(
    getDebouncedHandler(debounceWindow, (...args: Args) => {
      if (!isRemoved()) {
        return handler(...args);
      }
    }),
  );
 
  if (!isFunction) {
    handlerOrCallback.onRemove(wrapper.remove);
  }
 
  return wrapper;
};
 
/**
 * {@link Callback} wraps user-supplied callbacks. Supports
 * - removing a callback either when calling {@link remove} or if the user
 *   handler returns {@link Callback.REMOVE}
 * - calling custom {@link onRemove} hooks
 * - debouncing (via {@link wrap})
 * - awaiting on an asynchronous handler and ensuring that the handler does not
 *  run concurrently to itself, i.e. subsequent {@link invoke}s will be queued
 *
 * @typeParam Args The type of arguments that the callback expects.
 */
export class Callback<Args extends unknown[] = []> {
  /**
   * Possible return value for the handler.
   *
   * Do not do anything. Same as not retuning anything from the function.
   */
  static readonly KEEP: unique symbol = MC.SYMBOL(
    "KEEP",
  ) as typeof Callback.KEEP;
 
  /**
   * Possible return value for the handler.
   *
   * Will remove this callback.
   */
  static readonly REMOVE: unique symbol = MC.SYMBOL(
    "REMOVE",
  ) as typeof Callback.REMOVE;
 
  /**
   * Call the handler with the given arguments.
   *
   * If the handler is asynchronous, it awaits on it. Furthermore, calls will
   * always wait for previous calls to this handler to complete first, i.e. it
   * never runs concurrently to itself. If you need multiple calls to the async
   * handler to run concurrently, then wrap it in a non-async function that
   * does not await it.
   *
   * The returned promise is rejected in two cases:
   * - If the callback throws an error or returns a rejected Promise.
   * - If the callback is removed _after_ you call {@link invoke} but before the
   *   handler is actually called (while it's waiting in the queue to be called)
   *   In this case, the rejection reason is {@link Callback.REMOVE}.
   *
   * @throws {@link Errors.LisnUsageError | LisnUsageError}
   *                If the callback is already removed.
   */
  readonly invoke: (...args: Args) => Promise<void>;
 
  /**
   * Mark the callback as removed and call the registered {@link onRemove} hooks.
   *
   * Future attempts to call it will result in
   * {@link Errors.LisnUsageError | LisnUsageError}.
   */
  readonly remove: () => void;
 
  /**
   * Returns true if the callback has been removed and cannot be called again.
   */
  readonly isRemoved: () => boolean;
 
  /**
   * Registers the given function to be called when the callback is removed.
   *
   * You can call {@link onRemove} multiple times to register multiple hooks.
   */
  readonly onRemove: (fn: () => void) => void;
 
  /**
   * Wraps the given handler or callback as a callback, optionally debounced by
   * the given debounce window.
   *
   * If the argument is already a callback _or an invoke method of a callback_,
   * then the wrapper will call that callback and return the same value as it.
   * It will also set up the returned wrapper callback so that it is removed
   * when the original (given) callback is removed. However, removing the
   * returned wrapper callback will _not_ cause the original callback (being
   * wrapped) to be removed. If you want to do this, then do
   * `wrapper.onRemove(original.remove)`.
   *
   * Note that if the argument is a callback that's already debounced by a
   * _larger_ window, then `debounceWindow` will have no effect.
   *
   * @param debounceWindow If non-0, the callback will be called at most
   *                       every `debounceWindow` ms. The arguments it will
   *                       be called with will be the last arguments the
   *                       wrapper was called with.
   */
  static readonly wrap = wrapCallback;
 
  /**
   * @param handler The actual function to call. This should return one of
   *                the known {@link CallbackReturnType} values.
   */
  constructor(handler: CallbackHandler<Args>) {
    const logger = debug
      ? new debug.Logger({ name: "Callback", logAtCreation: handler })
      : null;
 
    let isRemoved = false;
    const id = MC.SYMBOL();
 
    const onRemove = MH.newSet<() => void>();
 
    this.isRemoved = () => isRemoved;
 
    this.remove = () => {
      if (!isRemoved) {
        debug: logger?.debug8("Removing");
        isRemoved = true;
 
        for (const rmFn of onRemove) {
          rmFn();
        }
 
        CallbackScheduler._clear(id);
      }
    };
 
    this.onRemove = (fn) => onRemove.add(fn);
 
    this.invoke = (...args) =>
      MH.newPromise((resolve, reject) => {
        debug: logger?.debug8("Calling with", args);
        if (isRemoved) {
          reject(MH.usageError("Callback has been removed"));
          return;
        }
 
        CallbackScheduler._push(
          id,
          async () => {
            let result;
            try {
              result = await handler(...args);
            } catch (err) {
              reject(err);
            }
 
            if (result === Callback.REMOVE) {
              this.remove();
            }
 
            resolve();
          },
          reject,
        );
      });
 
    callablesMap.set(this.invoke, this);
  }
}
 
// ----------------------------------------
 
type CallbackSchedulerTask = () => Promise<void>;
type CallbackSchedulerQueueItem = {
  _task: CallbackSchedulerTask;
  _running: boolean;
  _onRemove: (reason: typeof Callback.REMOVE) => void;
};
 
type CallableCallback<Args extends unknown[] = []> = (...args: Args) => void;
 
const callablesMap = MH.newWeakMap<CallableCallback, Callback>();
 
const CallbackScheduler = (() => {
  const queues = MH.newMap<symbol, CallbackSchedulerQueueItem[]>();
 
  const flush = async (queue: CallbackSchedulerQueueItem[]) => {
    // So that callbacks are always called asynchronously for consistency,
    // await here before calling 1st
    await null;
    while (MH.lengthOf(queue)) {
      // shouldn't throw anything as Callback must catch errors
      queue[0]._running = true;
      await queue[0]._task();
 
      // only remove when done
      queue.shift();
    }
  };
 
  return {
    _clear: (id: symbol) => {
      const queue = queues.get(id);
      if (queue) {
        let item: CallbackSchedulerQueueItem | undefined;
        while ((item = queue.shift())) {
          if (!item._running) {
            item._onRemove(Callback.REMOVE);
          }
        }
 
        MH.deleteKey(queues, id);
      }
    },
 
    _push: (id: symbol, task: CallbackSchedulerTask, onRemove: () => void) => {
      let queue = queues.get(id);
      if (!queue) {
        queue = [];
        queues.set(id, queue);
      }
 
      queue.push({ _task: task, _onRemove: onRemove, _running: false });
      if (MH.lengthOf(queue) === 1) {
        flush(queue);
      }
    },
  };
})();