slopware 101 · step 9 of 35
The error message is trying to tell you something
by Damla Su Bilge · 4 min read
Sometimes the page cannot even show the useful failure state we planned. It goes blank instead, leaving an error in the browser console. We will follow one such failure in the task list, from the first report to the smallest change that repairs it.
The example
A to-do app keeps its tasks in localStorage. Each task used to be saved as an object with a text field. A recent change renamed that field to title and added a search filter. Old saved tasks now fail during rendering.
To try this with synthetic data, make a new disposable folder containing two files. Save the first as search.js:
export function matchesSearch(task, query) {
return task.title.toLowerCase().includes(query.toLowerCase());
}
Save the second as index.html:
<ul id="tasks"></ul>
<script type="module">
import { matchesSearch } from './search.js';
const tasks = JSON.parse(localStorage.getItem('tasks') ?? '[]');
for (const task of tasks.filter(task => matchesSearch(task, ''))) {
const item = document.createElement('li');
item.textContent = task.title;
document.getElementById('tasks').appendChild(item);
}
</script>
Serve only this folder with python3 -m http.server 8001 --bind 127.0.0.1 and open http://127.0.0.1:8001. Its port separates the exercise storage from the earlier page on port 8000.
Reproduce it
For some people is not something you can debug. The first job is a set of steps that produces the problem every time, on your machine.
Ask what is different about the people it happens to. Here, they used the app before the change, so their browsers still hold tasks in the old shape. On a disposable local copy, you can recreate that in one line in the console. This replaces that origin's saved tasks, so use only synthetic data:
localStorage.setItem('tasks', JSON.stringify([{ text: 'buy thread', done: false }]));
Reload, and the page is blank. Reload again, still blank. Now you have a reproduction. It makes the behaviour repeat outside your own head, and it is what you hand to a model or a maintainer: not it is broken, but these steps and this result.
Expected and observed
Write two sentences. Expected: the page shows one task, buy thread. Observed: the page is blank and the console shows an error.
The bug lives in the difference between those sentences. Writing them down keeps you from fixing something nearby that was never the problem.
Read the message from the top
Browser formatting and line numbers vary. In a larger version of this example the trace might read:
Uncaught TypeError: Cannot read properties of undefined (reading 'toLowerCase')
at matchesSearch (search.js:2:21)
at Array.filter (<anonymous>)
at renderList (list.js:18:25)
at loadApp (main.js:9:3)
The first line says what went wrong: something called toLowerCase on a value that was undefined. Below it is the stack trace, the chain of function calls that were active at that moment, the most recent on top.
Read down until you reach the first frame in your own code. Here it is the top one, search.js, line 2, column 21. In a real app the top frames often belong to a framework or library. Start where the trace reaches your code so you can inspect what it handed to the dependency. If the evidence points into the library, follow it there. If the file names look like main.a83f.js:1:20931, you are reading bundled code, and the dev server or a source map will give you the original names.
export function matchesSearch(task, query) {
return task.title.toLowerCase().includes(query.toLowerCase());
}
task.title is undefined. The frame tells you where it broke. It does not tell you why. For that, ask where this task came from.
Shrink the input
Your disposable copy might hold forty synthetic tasks. Keep their original value in a separate scratch file, put back half of the list, and reload. Keep the half that still fails and repeat until one task is left. Then change one thing at a time. Rename text to title in that one task and reload, and the page works. Put it back and the page breaks.
One field, one change, the failure appears and disappears. That is the cause: data saved before the rename, read by code written after it.
Fix it narrowly
Two fixes are tempting. Wrap renderList in try and catch, or write task.title?.toLowerCase() everywhere. Either one stops the crash, and old tasks then quietly show up blank or never match a search. The symptom is hidden and the cause is spread further.
The narrow fix sits where the wrong shape enters, which is the moment tasks are loaded.
function loadTasks() {
const raw = JSON.parse(localStorage.getItem('tasks') ?? '[]');
return raw.map(task => ({
title: task.title ?? task.text ?? '',
done: Boolean(task.done),
}));
}
Place loadTasks inside the module script and replace its const tasks assignment with const tasks = loadTasks(). This handles the two known input shapes in the example. It is not a validator for arbitrary JSON: a real loader must also handle invalid JSON, non-array input and wrong field types without discarding the user's data. It is a small migration, and later chapters do the same thing with a database.
Check the fix the way you found the bug
Run the reproduction again. Put the old-shaped task in, reload, and the page shows buy thread and the search finds it. Then go the other way: take the fix out, run the same steps, and the page should be blank again. If removing the fix does not bring the failure back, you have not shown that the fix did anything.
Keep the one-task input as a test, so this bug stays fixed after the next rename.
We have a small input that fails, an explanation for it, and a change we can check by putting the old input back. Keep that evidence with the fix. Before making a larger change, though, it helps to know how to return to this working version. Reversing code, a live release and saved data are three different jobs.


