Todo List
Building a distributed todo list.
So far, we’ve focused on Y.Map
.
Let’s spend some time on another important shared type: Y.Array
.
This is a simple todo list. You can drag items around to reorder them and click the checkbox to check them off.
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
Here’s what the data looks like in JSON:
[
{ "id": "1", "text": "Check mail", "done": false },
{ "id": "2", "text": "Sweep floor", "done": false },
{ "id": "3", "text": "Buy milk", "done": false }
]
How would you implement the reordering functionality?
You might think we could just modify the array. If we had a normal JavaScript array, we could write something like this:
function move(todos, from, to) {
// remove the todo from the `from` index…
const [todo] = todos.splice(from, 1);
// …and insert it at the `to` index
todos.splice(to, 0, todo);
}
Just like before, though, things can get wonky when we start adding latency. See if you can break this one:
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
Did you manage to find a bug? Take a moment to think about why that might have happened before moving on.
Distributed Gotchas
Here’s a curious fact about Yjs. Once you add a shared type to a document, it can never be moved. You can change what it contains and you can delete it, but you can’t move it from one place to another.
That probably seems odd. There’s no way Yjs won’t let you reorder items in arrays, right?
Consider what the normal JavaScript code above is actually doing: first removing the element from the array, and second adding it somewhere else. “Move” is actually implemented as “delete” and “insert”.
Here’s a playground with a timeline, so you can see what’s happening under the hood:
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
Each client deletes a todo and inserts an identical one somewhere else. The issue is that Yjs doesn’t understand that there’s a connection between the delete and insert. As far as it can tell, the deleted item is totally unrelated to the inserted item.
If you made conflicting edits, there are two things that could have gone wrong:
- You lost a checkmark. On one client you checked or unchecked a todo, while on the other client you moved that same todo. That move deleted the todo you checked or unchecked, and the copy inserted elsewhere didn’t have the updated checkmark.
- You duplicated an item. On each client, you moved the same todo to different places. Since delete is an idempotent operation, there’s no problem if both clients do it at the same time. But as far as Yjs knows, each insert is different — even if the item being inserted is superficially the same.
Can you come up with a way to model the data that avoids this issue? Think about it before continuing!
A Better Data Model
Instead of keeping the todos in an array, try keeping them in a map.
Like its JavaScript counterpart, Y.Map
is technically ordered by when each key was inserted.
That won’t work for us, though: we want to be able to rearrange the todos into any order.
We can solve this by adding an index
property to each todo.
The ordered list of todos is an array of the map values sorted by the index
property.
To move a todo, simply set a new index
!
While that sort of works works, it presents a new problem. If you move a todo before another one, wouldn’t you need to change that todo’s index as well? Come to think of it, wouldn’t you need to change every subsequent todo’s index?
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
The trick is a technique called fractional indexing, in which indices are fractions rather than integers.
Each item receives a fraction between 0
and 1
, exclusive.
To move an item, simply set its index to a fraction between the two adjacent items’ indices.
Usually, that’s as simple as taking the average of the two indices: add them together and divide by two.
The “exclusive” part is important.
Because no item will ever have an index of 0
or 1
, those numbers can be used to move items to the extremes of the list.
Moving an item to the beginning of the list means finding a fraction between 0
and the first item, while moving an item to the end means finding a fraction between the last item and 1
.
Excercise 1
Let’s try implementing fractional indexing. We’ll be creating two functions:
getTodos(map)
takes a map of todos and returns them in an array, sorted in ascending order by theirindex
property.move(map, from, to)
takes a map of todos and afrom
andto
index, and moves the todo from one to the other.
Implement the functions to rearrange and sort the todos using their fractional indices. Remember, there are hints and unit tests below to help you!
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
Unit tests4/4
- getTodos() returns all the todos sorted by index
- move() doesn't change the order if `from` and `to` are the same
- move() moves todos between other todos
- move() moves todos to the edges of the list
Hint 1
Remember that Y.Map
has many of the same methods as JavaScript’s Map
, including .values()
for getting all the map values!
Hint 2
move
relies on a sorted list of todos in order to find the adjacent todos.
Once you’ve gotten getTodos
working, feel free to call it inside move
!
Hint 3
The move
function involves some tricky index fiddling.
If you’re moving a todo earlier in the array, the first todo will be at index to - 1
and the second will be at index to
.
But if you’re moving it later in the array, the first todo will be at index to
and the second will be at to + 1
!
Click to reveal solution
Solution
To get the list of todos, we get all the values from the map, then sort them by their index
property:
export function getTodos(map) {
const values = [...map.values()];
return values.sort((a, b) => a.get('index') - b.get('index'));
}
The move
function is trickier.
For starters, since to
and from
are indices, we need a sorted list of todos.
Luckily, we just wrote a function to do that!
export function move(map, from, to) {
const todos = getTodos(map);
const todo = todos[from];
if (!todo) return;
}
Next, we need to find the adjacent todos. The indices of those todos depend on whether we’re moving the todo earlier or later in the list.
If we’re moving it earlier, the first todo will be at index to - 1
and the second will be at index to
.
If we’re moving it later, the first todo will be at index to
and the second will be at to + 1
.
export function move(map, from, to) {
// ...
const earlier = from > to;
const before = todos[earlier ? to - 1 : to];
const after = todos[earlier ? to : to + 1];
}
After we have the todos, we need to get their indices.
Usually we can just get the index
property — but remember that each todo might be undefined!
If we’re moving a todo to the beginning of the list, we want to set the index between the first todo’s index and 0
.
Ditto for the end of the list, except with the last todo’s index and 1
.
export function move(map, from, to) {
// ...
const start = before?.get("index") ?? 0;
const end = after?.get("index") ?? 1;
}
After all that, we set the new index to the average of the start and end indices:
export function move(map, from, to) {
// ...
todo.set("index", (start + end) / 2);
}
Here’s the full move
function, for posterity:
export function move(map, from, to) {
const todos = getTodos(map);
const todo = todos[from];
if (!todo) return;
const earlier = from > to;
const before = todos[earlier ? to - 1 : to];
const after = todos[earlier ? to : to + 1];
const start = before?.get("index") ?? 0;
const end = after?.get("index") ?? 1;
todo.set('index', (start + end) / 2);
}
Note that while this solution uses floating point numbers for simplicity, in real life you should use decimals with arbitrary precision.
Conflicting Writes
You might have noticed a problem with the fractional indexing algorithm.
Here’s a playground that uses fractional indexing. See if you can find the bug:
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
With the latency high, drag a different todo to the top on each client. If you look at the document, they’ll both have the same index value.
Try dragging the last todo between them. Now that one has the same index, too!
Two items with the same index is really bad. It means that we can’t ever move any items between them. Since their indices are equal, their average will just be the same number.
Excercise 2
See if you can figure out how to make sure that two todos will never share an index — even if both clients move them to the same spot.
We’ve moved your code from the last exercise over here, so you can pick up where you left off.
- Check mail
- Sweep floor
- Buy milk
- Check mail
- Sweep floor
- Buy milk
Unit tests5/5
- getTodos() returns all the todos sorted by index
- move() doesn't change the order if `from` and `to` are the same
- move() moves todos between other todos
- move() moves todos to the edges of the list
- concurrent move() calls find different indices
Hint 1
When you move two todos to the same slot, they end up with the same index because they’re both taking the average of the same two neighboring indices. Can you think of a way for them to arrive at different results?
Hint 2
Normally, we want our code to be deterministic so that multiple clients can agree on the same result. In this case, though, we don’t want to arrive at the same result: we want clients to come up with different indices from other clients. The only way to do that without coordinating with the other clients is to use some form of randomness.
Click to reveal solution
Solution
To prevent the two todos from sharing the same index, we can pick a random number between their indices rather than averaging them.
Since Math.random()
returns a number that could equal 0
, we add Number.MIN_VALUE
to ensure the resulting index is between the start and end, exclusive.
export function move(map, from, to) {
// ...
const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
todo.set('index', index);
}
If we’d rather the index stayed closer to the middle, we could instead add a small jitter — a randomized offset to the average. In this case, we’d need to ensure the jitter is smaller than the distance between the two indices:
export function move(map, from, to) {
// ...
const average = (start + end) / 2;
const range = (end - start) * 1e-1000;
const jitter = -range / 2 + Math.random() * range;
todo.set('index', average + jitter);
}
Here’s the full move
function using the random number approach:
export function move(map, from, to) {
const todos = getTodos(map);
const todo = todos[from];
if (!todo) return;
const earlier = from > to;
const before = todos[earlier ? to - 1 : to];
const after = todos[earlier ? to : to + 1];
const start = before?.get("index") ?? 0;
const end = after?.get("index") ?? 1;
const index = (after - before) * (Math.random() + Number.MIN_VALUE) + before;
todo.set('index', index);
}
While neither approach makes it 100% certain that no todos will ever share an index, in practice it becomes extremely unlikely.
As a reminder, a real implementation should use arbitrary-precision decimals rather than floating point numbers.