GitLab task lists are
a list of checkboxes that you can include anywhere in GitLab where you can have
GitLab Flavored Markdown (GFM).
This includes issue descriptions and comments, as well as merge requests and epics.
They can be used for a list of items to consider when building a feature, tracking
tasks for new employees to complete when onboarding, or even managing that list
of materials to purchase for your next home renovation. You can use them as todo
lists, and so checking off an item should be quick and satisfying.
More checkboxes, more problems
In the past, task lists with several items, even dozens, worked fairly well. Check
an empty checkbox, and a database record gets updated. The checkbox is then displayed
as checked. Done.
However, as the number of items increases, and the consequent
markdown becomes more complex and longer, problems begin to appear. For example,
visually the checkbox appears checked, but because updating the backend takes a
longer time, if you checked another checkbox, the screen would refresh several seconds
later and the checkbox might then be unchecked. It soon became next to impossible
to go down a list and check off items without waiting 10 seconds between each one.
Yet another problem was that if other users were also checking items on the list,
your change could be erased by them checking their item – they were overwriting
In GitLab 11.8 (released on Feb. 22, 2019),
we significantly increased the performance of task lists, as well as making them
much more robust. Here's how we did it:
Essentially we wanted:
- Checking a checkbox to be as fast as possible.
- Many users to concurrently interact with checkboxes in the same task list,
without overwriting each other.
Both the performance and data integrity issues stemmed from the fact that we were
updating the complete markdown. This meant that we changed the markdown source in
the browser with the updated checkbox, sent it to the backend, where it was saved
to the database, and then re-rendered so that we could cache the new and send
it back to the user.
A scalable solution
But what if we could update a single checkbox, and send only that to the backend? That
might allow multiple users to check off as many tasks as they wanted, without clobbering
each other. And what if we didn't have to do any markdown rendering at all? We wouldn't
have to do any markdown processing, or process embedded issue links, or query if
labels have changed, or any of the other advanced things that go on when updating
an issue. Performance would definitely increase in this case.
On the frontend, with only a small modification to the
gem we use, we were able to pass the exact text and line number in the markdown source
for the clicked task.
Wrap this piece of information
in a new
update_task parameter for our update endpoint, and send it to the backend.
On the backend,
we needed to verify
that the task we were interested in still existed in exactly the same format – the text had to match
the exact line number in the source. This meant that even if someone changed text above or below
the task item, as long as our line matched exactly, we could update that line in the latest source
and save it without losing changes.
In order to update our cached HTML so that we wouldn't have to re-render it, we turned on
SOURCEPOS flag of the CommonMark renderer, which adds a
data-sourcepos attribute to the HTML.
For example, a task item's might look like this:
<li data-sourcepos="1:1-1:12" class="task-list-item"> <input type="checkbox" class="task-list-item-checkbox" disabled> Task 1 </li>
With a little Nokogiri magic we were able to find the correct line
and toggle the checked attribute.
Since we updated the cache directly, we completely bypassed any markdown rendering,
processing of special attributes, etc. Performance dramatically increased. However,
since we are not able to get it down to zero, we disabled the checkboxes while the
request was in flight to ensure we weren't getting clicks on other tasks.
The result: a much more satisfying task list.