Writing a tiny undo/redo stack in JavaScript
blog.julik.nl
blog.julik.nl
So I rewrote it and added logic to merge adjacent changes [1] which helped immensely, enough that undo/redo felt instant again. It looks like the core merging logic was rewritten a few years ago [2] (which caused a regression, is imo less readable, and removed useful comments -_-) but the basic idea is still there today.
[1]: https://github.com/VSCodeVim/Vim/commit/0576f199cb7a765efb3c...
[2]: https://github.com/VSCodeVim/Vim/blame/v1.29.0/src/history/h...
[1] https://github.com/VSCodeVim/Vim/pull/496#issuecomment-23547...
Beauty is when practicality and perception pull in parallel towards purpose. Sacrificing one for the other can have a certain aesthetic quality, but I would not call it beautiful.
Then there are things where the browser already implements undo functionality like navigation (back button) and editing text boxes. Your undo is going to have to work nicely with those.
You'll either end up with a buggy undo implementation, or end up spending a lot of engineering time hunting corner cases.
Can't exactly log into facebook, post something to your wall, and then press Command+Z to undo the post, and the Command+Z again to undo the login...
No - both of the undo's have a special way to do it - click "delete post" followed by "log out".
Command-Z and Command-Y should be for document-based undo. Give the user some way of knowing what document they're working on. Even if the user is going to use the keyboard it helps to have Undo/Redo icons so they know what Undo/Redo applies to. Now, documents in code are more complex, but there's a wealth of research on it, and stuff like CRDTs and the history systems of libraries like ProseMirror and CodeMirror.
And often the undo of deleting a post is needed, but that's a separate thing, nowadays handled by the snackbar components. And if you really needed a redo, that would be something to put in a history feature.
Navigation does have a stack which works the same way as undo/redo but it doesn't normally use Command-Z and Command-Y.
First you mark all of your app memory as read only, so that you can define a SIGBUS handler when attempts are made to write to it.
Then when you detect a write to a page in the app memory, you append a copy of that page's memory to a stack, and then mark the page as writeable so that the page can be updated.
Then undo is simply a matter of popping the stack and overwriting the app memory with the copy stored on the stack entry.
This means you don't have to explicitly write "actions" for everything you want to undo/redo. All in-memory actions are handled automatically.
Obviously this means that things like FS changes or network IO changes aren't captured, but sometimes that's fine, and I'm sure there's a way to extend this scheme to handle those things somehow
* How do you make sure you capture all mapped memory within a process? Walking something like /proc/self/maps?
* Does this include the stack and if so, doesn’t this cause havok when you write back. And if it doesn’t include the stack, can’t run things out of sync easily?
* This is then a page sized undo/redo, so it isn’t atomic with regards to actions triggered. Wouldn’t partially undoing such changes easily cause an invalid state?
1. I don't heap allocate anything after init. All allocations are custom allocated from this memory. I use a linear allocator, but could use some other scheme.
2. I don't account for the stack at all, but it doesn't mess anything up. The only important memory is the app memory, and that persists across frames.
3. It is, but you're computing deltas across frames, so they are atomic wrt user input. It's true that if multiple changes are made to the same page in the same frame then undoing that page undoes all of the changes, but that is often desirable from the user's perspective.
Anyway perhaps there's a place for your solution at the compiler/language level, at various granularities. I've played with the thought, but considered it impractical so far considering those always changing requirements. Also, memory use might go through the roof for some applications.
Want to group undo actions? It's pretty easy to define arbitrary grouping algorithms on the sequence of recent state snapshots, which yields...a smaller sequence of state snapshots :-) a nice composeable idea.
To show redo after an undo, store recent undo triggers in the state, and if there is undo in current state, show redo with a list of older mem states to jump to. This repr also makes it easy to wipe out redos when doing a redo-breaking operation like editing the doc.
Memory use can indeed become a problem. But if/when it does, there is probably some obvious trait of the sequence of recent states that makes it easy to compress. Like for example that it's 99% the same contents, after a small edit. Or that one edit type dominates. Or that folks don't often care if they can't Ctrl+Z through 8GB of typing.
I think most programmers would gain by not dismissing simple approaches out of hand as too simple.
If writing it off for memory, write out the math for the size of the state and the allotted memory. This is not the era of computers that our languages were invented in; the page size your CPU fetches from insanely fast SSDs can be 4kb. And it can do a lot of that in a blink. Trying to shift runtime work (that the computer does) back to compile/design time (that the dev does) can cripple a system's performance in the short run (for coding/delivery time) and long run (for performance after pointer chasing)...luckily most of the time it doesn't matter anyway :-)
Other notable inspirations https://immerjs.github.io/immer/patches/ https://github.com/webstudio-is/immerhin
foo = []; Will reassign the reference but other pointers can still have the array.
foo.length=1 will remove all elements but the first.
"If deleteCount is omitted, or if its value is greater than or equal to the number of elements after the position specified by start, then all the elements from start to the end of the array will be deleted."
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
In most cases, there isn't a beneficial performance boost from emptying an array this way. One needs to allocate in a loop or do something equally inefficient to measure the difference between `coll = []` and `coll.length = 0`. (This assumes the language runtime does not statically allocate an immutable, empty array and use that to represent all empty arrays. Clojure does this, for instance, so no allocations occur when binding a variable to [] or other empty collections).
In JavaScript these two statements have very different effects.
let doFn, undoFn; {const newStroke = currentStroke; doFn = ()=>strokes.push(newStroke); undoFn = ()=>strokes.pop();}In the code above, `newStroke` just makes a reference to `currentStroke`. Nothing is changed by creating a `const newStroke` and pushing it to the array. You're just pushing a reference to the original. In the OP's example, `currentStroke` is something that gets modified. If you used the code above, as soon as you modify `currentStroke`, every reference in the `strokes` array will change. The fact that you used `let` instead of `var` in the loop means you are creating a new constant, but that constant isn't preserving a snapshot of `currentStroke` unless you explicitly make it a new object. Otherwise it's just a reference. You need to deep-clone it in the undo function in order to preserve its previous state. Something like:
`let previousStroke = {a:currentStroke.a,b:currentStroke.b}` where `a` and `b` are primitives that are copied, not objects that are referenced, before you change `currentStroke`. If you did it manually, you'd have to keep keep recursively checking `a` and `b` all the way down to their primitives, and rebuild the object.
An easy way of doing this for objects without functions or classes in them is just `const previousStroke = JSON.parse(JSON.stringify(currentStroke));` but usually I've had to write custom re-initializers if for example `previousStroke.a` was a class instance.
Not true. If you look carefully you will see the `const` and everything after it is wrapped in curly braces, and those braces create a block...
> Traditionally (before ES6)... blocks with curly braces do not create scopes... [but] blocks are finally treated as scopes in ES6, but only if you declare variables with let or const. — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guid...
So the curly braces with the use of const/let creates a new lexical scope, hence solving the issue. You can test it in the browser console to confirm;
var stack=[], cmd="";
cmd="success"; stack.push(()=>cmd);
cmd="fail"; stack.push(()=>cmd);
stack[0](); /* returns "fail" */
var stack=[], cmd="";
cmd="success"; {const c=cmd; stack.push(()=>c);}
cmd="fail"; {const c=cmd; stack.push(()=>c);}
stack[0](); /* returns "success" */
It's probably not a good idea to do it this way, because it's easily "overlooked". I would probably use a class or factory-function if I had the need.Nice to see it distilled into something so straightforward! Thanks for sharing!
Of course, the end result can be thought of as a kind of specialized mutation observer. It's "reactive" to adding certain kinds of children and alerting you when those children change. The specialization, though, is in how it also implements the structural notion of a timeline and can therefore provide "back" and "forward" and "point in time" information about the children during events. This makes it a lot more useful for history than a simple mutation observer.
Of course, at the end of the day, it's only exposing the objects that you put in to the history back to you, so it's entirely on the implementing dev to decide how to display and what gets done with "action" data. In that way, it's not actually doing the functional work that one might expect of a history framework. But the intention is to keep it agnostic. This same history component could work with CRDTs as easily as a simple local data store or a REST api.
The main thing I think of it as is an "input" that can give the implementing dev a good idea of what the user is trying to do. An "undo", a "redo", or a "jump to point". That way I can drop it in to any web project and all I need to implement is "what happens on undo", "what happens on redo", etc...
And then, of course, that last part about using it wherever is why it was built as a "web component" (custom element, but whatever). Include a single js script, and I can start using the component like a text input - a way to collect user input so that I can implement what happens during the interactions I want to influence.
ETA: Off topic, but since I've got you here...
I actually created the action-history element for use in a drawing web app I had been messing with. It's meant to mimic the design and functionality of the history panel in photoshop. To that end, given your examples, I wonder if it might have some use for what you were trying to do?
So you can probably trap Meta-Z but some people use menus or the other affordances.
When are they gonna add a proper UI for this? I look like a moron using the phone this way, and shaking only works to trigger the undo about half the time.
Sometimes I think I have to use a paint can shaker on a 5-minute cycle just to trigger it.
Man I get gestures have been embraced by the community, but this is emphatically not what I meant. The only gesture that makes sense to me is pinch-to-zoom and maybe swiping on cards that appear to be floating.
There's no way to discover this functionality outside of googling documentation and it often doesn't work with one hand.
Cynically, I think you'd just look like even more of a fool shaking an ipad and this is the only reason they approved such a button.
https://hypervariety.com/NativeBrowserUndo/NativeBrowserUndo...
Very useful, if you know how to summon it!
It really is better to have two separate arrays - unless your perf requirements dictate that you, for example, preallocate your entire stack and keep it fixed size.
When I last implemented it, it was for an ambitious project, where I just got it working and then moved onto something else. The single stack seemed to work pretty well. https://codeberg.org/macchiato/ristretto/src/branch/window-w...
I used .at(), I'm not sure why, with a check to make sure it isn't under 0 at the right place, so the -1 behavior wasn't an issue. To see it working go to https://ristretto.codeberg.page/ on desktop and click client-server-simulator.md
Edit: I think it feels better to use at because it seems an accident of history that using an index doesn't error in JavaScript, while with at() it seems to be on purpose, like how .get() in python dicts doesn't error on a missing key.
Undo-redo is in tension with how a lot of people like to program, namely using (non-undoable) effects.