Reversibles

This tiny script provides you with a way to write reversible functions. That is to say, you can call a function, it will keep track of its (reversible) dependencies, give you an undo callback and so you can just... Undo everything you did with zero effort.

Usage

This module gives you two functions: reversible and until. The former is the important one, so let's see an example of that first!

const attachAutoComplete = reversible(input => {
    when(input).changes().then(() => autoComplete(input))
})

// call it in a non-reversible way:
attachAutoComplete(myInput)

// call it in a reversible way:
const {undo} = attachAutoComplete.do(myInput)
// and undo it! In this case, it detaches the 'change' event listener.
undo()

The first thing you might notice is the way the event listener is bound - that's not an addEventListener, what's up with that? Well, it's the reversible version of addEventListener. Or, a reversible version. You can write your own! This when implementation can be found in the "library" folder, if you're interested. Anyway, that brings me to one of the key things about reversible functions; they can only ever undo their reversible dependencies. It's all JavaScript, not magic, and so the reversible wrapper cannot see anything you do in your function unless it is also reversible. Yes, you could change the EventTarget's prototype and overwrite addEventListener with a reversible version, but I refuse to touch prototypes. It's also probably a lot easier to maintain when there is a clear distinction between reversible functions and vanilla ones, but you're free to do whatever you like 😉

Function signatures

A function defined with reversible will retain its original signature. You can even still bind this to it. The function returned by reversible however also has a do method, which returns a "call object", exposing a way to undo a call. Parameters to do work as you'd expect, nothing changes relative to the original function in that respect. The return value is where it's at, and it looks like this:

interface ReversibleCall<T> {
    result: ReturnType<T>;
    undo: () => void;
    undone: boolean;
}

The result property simply contains the return value of the original function. If the reversible is asynchronous, result will be a promise. You also get the undo function, which takes no arguments, and returns nothing (i.e. it returns undefined). You may call it more than once, but it will only undo things the first time you call it. Lastly, and probably least important, you get a getter undone telling you whether the call has been undone or not. Note that destrucuring this property will just give you false, because you'd be immediately invoking the getter - keep a reference to the whole object around so you can check it like so:

const foo = reversible(() => {
    // do reversible stuff
    return 23
})
const call = foo.do()
console.log(call.undone) // false
console.log(call.result) // 23
call.undo()
console.log(call.undone) // true

Async reversibles (and until)

You might want to write an asynchronous reversible function, because let's face it: await is awesome. I'm on your side here, and while this can't work straight out of the box, the module provides you with a function called until that makes things relatively easy. Synchronous reversibles rely on the synchronicity of the function to know when to track dependencies and when to stop, but await messes that up a bit. The until function should be wrapped around any awaited expression to help the function keep track of its dependencies. It looks somewhat like so:

const uploadFile = reversible(async () => {
    const [fileHandle] = await until(window.showOpenFilePicker())
    const file = await until(fileHandle.getFile())
    // I also want to show off this syntax, because I think it's real nice
    // Let's assume we're submitting the file in a form
    await until(when(form).submits())
    console.log('Form submitted successfully!')
})

Note: do not bake until into the return value of asynchronous functions, that won't work. Always write await until(/* expression */).

As for undoing an asynchronous function, there's something you should keep in mind. Since you can call undo before the function has finished running, and aborting an async reversible early would mean creating a promise that will never be settled, the whole reversible will still be run every time you call it. Even when calling undo right away, it will wait for the call to finish running before starting to undo anything. It will however queue the undoing for when the reversible is done, so calling .undo early is still fine.

Helper reversibles

There's an important distinction to make, that I have not brought up yet. You see, reversible functions only ever reverse their dependencies, but they don't know how to undo anything substantial. That's why we need helper reversibles, that are, in a way, at the end of the dependency chain. They are the ones that have to explicitly say how they should be undone. The event listening through when is an example of this; when explicitly defines how an event listener should be taken down. You can write these helper reversibles yourself; in fact, I encourage you to! The way to do this is fairly simple, so let's dive into an example.

const test = reversible.define(number => {
    console.log(`Test ${number} ran!`)
    const undo = () => {
        console.log(`Test ${number} has been undone!`)
    }
    const result = `Test ${number}'s return value`
    return {result, undo}
})

The above helper reversible simply logs a message telling you when it has been run, and when it has been undone. The important part here is that you return an object with an undo method on it, that defines how to undo the function body. Don't worry about undo's signature; it will be normalized (i.e. wrapped in another function, which returns undefined always). The return value of your function should be set in the result key. This is optional though; it will default to undefined, as you'd expect.

Note that this also means you cannot write asynchronous helper reversibles; the return value needs to have the undo method, and asynchronous functions return a promise (which do not have that). You can still return a promise for the result, however.

For some more examples for how to define helper reversibles, take a look at the "library" folder! Each example has a readme as well to explain what they do exactly.