frontend-challenges icon indicating copy to clipboard operation
frontend-challenges copied to clipboard

432 - Throttle with Leading and Trailing - javascript

Open jsartisan opened this issue 2 months ago • 0 comments

index.js

export function throttle(func, wait, options = {}) {
  const { leading = true, trailing = true } = options;

  let timer = null;
  let lastArgs = null;
  let lastThis = null;

  const later = () => {
    if (lastArgs && trailing) {
      func.apply(lastThis, lastArgs);
      lastArgs = lastThis = null;
      timer = setTimeout(later, wait);
    } else {
      timer = null;
    }
  };
  return function (...args) {
    lastArgs = args;
    lastThis = this;
    if (!timer) {
      if (leading) {
        func.apply(this, args);
        lastArgs = lastThis = null;
      }
      timer = setTimeout(later, wait);
    }
  };
}

index.test.js

const { throttle } = require("./index");

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

describe("throttle", () => {
  it("calls function immediately when leading is true", () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100);

    throttled("A");
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith("A");
  });

  it("waits before next call within delay", async () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100);

    throttled("A");
    throttled("B");

    expect(fn).toHaveBeenCalledTimes(1);

    await wait(120);

    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenLastCalledWith("B");
  });

  it("supports disabling leading", async () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100, { leading: false });

    throttled("A");
    expect(fn).not.toHaveBeenCalled();

    await wait(120);
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith("A");
  });

  it("supports disabling trailing", async () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100, { trailing: false });

    throttled("A");
    throttled("B");

    await wait(120);

    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith("A");
  });

  it("preserves context and arguments", async () => {
    const fn = jest.fn(function (x) {
      return this.value + x;
    });
    const obj = { value: 10 };
    const throttled = throttle(fn, 100, { leading: true, trailing: true });

    throttled.call(obj, 5); // leading
    throttled.call(obj, 7); // trailing

    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith(5);
    expect(fn.mock.instances[0]).toBe(obj);

    await wait(120);

    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenLastCalledWith(7);
    expect(fn.mock.instances[1]).toBe(obj);
  });

  it("does nothing if both leading and trailing are false", async () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100, { leading: false, trailing: false });

    throttled("A");
    throttled("B");

    await wait(120);
    expect(fn).not.toHaveBeenCalled();
  });

  it("overrides trailing call with latest arguments", async () => {
    const fn = jest.fn();
    const throttled = throttle(fn, 100, { leading: true, trailing: true });

    throttled("A"); // leading
    throttled("B"); // trailing scheduled
    throttled("C"); // trailing overrides previous

    await wait(120);

    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenLastCalledWith("C");
  });
});

jsartisan avatar Sep 21 '25 17:09 jsartisan