Skip to the code
cf.

Toast queue

A toast is a short message that pops up on a page and goes away on its own after a few seconds. Write a class ToastQueue that decides which toasts are on screen. It only holds the state; drawing the toasts is someone else's job.

new ToastQueue(options) takes maxVisible, how many toasts can be on screen at once (3 by default), and duration, how many milliseconds each toast stays on screen (5000 by default). options and both of its properties are optional.

show(message) adds a toast and returns its id: 1 for the first toast, 2 for the next, and so on. The toast goes on screen when there's room. Otherwise it waits in line behind the toasts already waiting.

visible and queued are getters for the toasts on screen and the toasts waiting, oldest first. Each returns a new array of { id, message } objects.

A toast is dismissed duration ms after it goes on screen. Time spent waiting doesn't count.

dismiss(id) dismisses a toast early, whether it's on screen or waiting. An id that isn't on screen or waiting is ignored.

When a toast on screen is dismissed, by time or by dismiss, the oldest waiting toast takes its place, and its duration starts then.

subscribe(listener) calls listener(visible) every time the list of visible toasts changes, and returns a function that unsubscribes. Each change calls it once: a dismissal that brings a waiting toast on screen is one change. Adding a toast that has to wait, or dismissing a waiting one, doesn't change the visible list, so it calls no one.

const toasts = new ToastQueue({ maxVisible: 2, duration: 3000 });
toasts.subscribe((visible) => console.log(visible.map((toast) => toast.message)));
 
// At 0 ms:
const saved = toasts.show("Saved"); // 1, logs ["Saved"]
toasts.show("Copied");              // 2, logs ["Saved", "Copied"]
toasts.show("Deleted");             // 3, logs nothing: it waits
toasts.queued;                      // [{ id: 3, message: "Deleted" }]
 
// At 1000 ms:
toasts.dismiss(saved);              // logs ["Copied", "Deleted"]
 
// At 3000 ms, "Copied" is dismissed: logs ["Deleted"]
// At 4000 ms, "Deleted" is dismissed, 3000 ms after it went on screen: logs []

What the tests check

  1. shows up to maxVisible toasts and queues the rest, oldest first
    useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 2, duration: 1000 });
    expect(toasts.show("a")).toBe(1);
    expect(toasts.show("b")).toBe(2);
    expect(toasts.show("c")).toBe(3);
    expect(toasts.show("d")).toBe(4);
    expect(toasts.visible).toEqual([{ id: 1, message: "a" }, { id: 2, message: "b" }]);
    expect(toasts.queued).toEqual([{ id: 3, message: "c" }, { id: 4, message: "d" }]);
    
  2. dismisses each toast duration ms after it goes on screen
    const clock = useFakeTimers();
    const toasts = new ToastQueue({ duration: 1000 });
    toasts.show("a");
    clock.tick(500);
    toasts.show("b");
    clock.tick(499);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["a", "b"]);
    clock.tick(1);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["b"]);
    clock.tick(500);
    expect(toasts.visible).toEqual([]);
    
  3. a waiting toast takes the first free place, and its time starts then
    const clock = useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 1, duration: 1000 });
    toasts.show("a");
    toasts.show("b");
    clock.tick(1000);
    expect(toasts.visible).toEqual([{ id: 2, message: "b" }]);
    expect(toasts.queued).toEqual([]);
    clock.tick(999);
    expect(toasts.visible).toEqual([{ id: 2, message: "b" }]);
    clock.tick(1);
    expect(toasts.visible).toEqual([]);
    
  4. dismiss removes a toast on screen early and brings in the next one
    const clock = useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 2, duration: 1000 });
    const a = toasts.show("a");
    toasts.show("b");
    toasts.show("c");
    clock.tick(400);
    toasts.dismiss(a);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["b", "c"]);
    clock.tick(600);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["c"]);
    clock.tick(399);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["c"]);
    clock.tick(1);
    expect(toasts.visible).toEqual([]);
    
  5. dismiss removes a waiting toast, and ignores unknown ids
    const clock = useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 1, duration: 1000 });
    const listener = spy();
    toasts.show("a");
    const b = toasts.show("b");
    toasts.show("c");
    toasts.subscribe(listener);
    toasts.dismiss(b);
    toasts.dismiss(99);
    expect(toasts.queued).toEqual([{ id: 3, message: "c" }]);
    expect(toasts.visible).toEqual([{ id: 1, message: "a" }]);
    expect(listener).not.toHaveBeenCalled();
    clock.tick(1000);
    expect(toasts.visible).toEqual([{ id: 3, message: "c" }]);
    
  6. calls subscribers with the visible toasts, once per change
    const clock = useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 1, duration: 1000 });
    const listener = spy();
    toasts.subscribe(listener);
    const a = toasts.show("a");
    toasts.show("b");
    expect(listener).toHaveBeenCalledTimes(1);
    expect(listener).toHaveBeenLastCalledWith([{ id: 1, message: "a" }]);
    clock.tick(400);
    toasts.dismiss(a);
    expect(listener).toHaveBeenCalledTimes(2);
    expect(listener).toHaveBeenLastCalledWith([{ id: 2, message: "b" }]);
    clock.tick(999);
    expect(listener).toHaveBeenCalledTimes(2);
    clock.tick(1);
    expect(listener).toHaveBeenCalledTimes(3);
    expect(listener).toHaveBeenLastCalledWith([]);
    
  7. the function subscribe returns stops the calls
    useFakeTimers();
    const toasts = new ToastQueue();
    const listener = spy();
    const unsubscribe = toasts.subscribe(listener);
    toasts.show("a");
    unsubscribe();
    toasts.show("b");
    expect(listener).toHaveBeenCalledTimes(1);
    
  8. shows 3 toasts for 5000 ms by default
    const clock = useFakeTimers();
    const toasts = new ToastQueue();
    for (const message of ["a", "b", "c", "d"]) toasts.show(message);
    expect(toasts.visible).toHaveLength(3);
    expect(toasts.queued).toHaveLength(1);
    clock.tick(4999);
    expect(toasts.visible).toHaveLength(3);
    clock.tick(1);
    expect(toasts.visible.map((toast) => toast.message)).toEqual(["d"]);
    const single = new ToastQueue({ maxVisible: 1 });
    single.show("x");
    clock.tick(4999);
    expect(single.visible).toHaveLength(1);
    clock.tick(1);
    expect(single.visible).toHaveLength(0);
    
  9. visible and queued return new arrays that can't change the queue
    useFakeTimers();
    const toasts = new ToastQueue({ maxVisible: 1 });
    toasts.show("a");
    toasts.show("b");
    toasts.visible.pop();
    toasts.queued.push({ id: 9, message: "x" });
    expect(toasts.visible).toEqual([{ id: 1, message: "a" }]);
    expect(toasts.queued).toEqual([{ id: 2, message: "b" }]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).