In Notifio a "search" is one rental search results page the app watches for you: you paste the URL from the site, name it, and the monitor checks it on a cycle. The UI shows a row per search with its own status, countdown, session state and find count.
One row. Three different keys identify it, depending on which part of the app you ask.
| Identity | Who uses it | Lifetime |
|---|---|---|
| Position in an array | the HTTP API that edits it | changes whenever another search is added or removed |
| The search URL | the monitor's live state and its diff baseline | changes when you re-point the search |
| The hostname | the saved browser session and the sign-in state | never changes for a given search |
They are in exactly the wrong order. The most volatile key is the one that travels over the wire.
Why position at all
Searches live in config.json in the app's writable data directory, as an array of objects with a name, a URL and some flags. No ids. So the API that edits them addresses them the way the file does:
app.delete('/api/sites/:index', (req: Request, res: Response) => {
const idx = parseInt(req.params['index'] as string, 10);
const cfg = loadConfig();
// Number.isInteger guards against NaN (e.g. a non-numeric :index). Without
// it, NaN slips past the range check and splice(NaN,1) deletes the first site.
if (!Number.isInteger(idx) || idx < 0 || idx >= cfg.sites.length) {
return res.status(404).json({ error: 'Not found' });
}
const [removed] = cfg.sites.splice(idx, 1);
saveConfig(cfg);
// Clean up the removed search's baseline snapshot so it doesn't linger.
if (removed) monitor.deleteSnapshot(removed.url);
res.json({ ok: true });
});
That comment is load-bearing and it is there because of a real incident: parseInt returned NaN and splice(NaN, 1) deleted the first search, which is the worst possible failure mode for a positional API, because it succeeds.
Adding ids is the obvious fix and I have not done it. The reason is that config.json is the file the monitor re-reads on every poll and the file that already exists on every installed copy, holding the licence and every search a user has set up. Any id scheme means a migration on the one file I least want to rewrite, to solve a problem that one function solves instead.
The one function
Every mutation in the UI is addressed by URL, and the store translates:
// The reducer's own `sites` is the authority for resolving a search's index,
// and a ref keeps that readable from callbacks without making every action
// depend on the current render's copy.
const sitesRef = useRef<Site[]>([]);
sitesRef.current = state.sites;
const indexOf = useCallback((url: string): number => {
return sitesRef.current.findIndex((s) => s.url === url);
}, []);
const removeSearch = useCallback(
async (url: string) => {
const idx = indexOf(url);
if (idx < 0) return;
await deleteSite(idx);
await refreshSites();
},
[indexOf, refreshSites]
);
No component ever sees an index. They call removeSearch(site.url), setEnabled(site.url, false), setSearchUrl(site.url, next), and the translation happens in one file, from one array, at the moment of the call.
The header comment on that file says what it replaced:
The REST API addresses searches by position, and components were passing their own render index, and one add or remove elsewhere meant a patch would land on the wrong search.
That is the bug this shape exists to make impossible. A component's render index is a snapshot of a list that may have changed since it rendered, especially in a UI where a background process can cause a re-render at any moment. Resolving the index at call time from the reducer's current array closes the window to approximately nothing.
Two small things in there carry more weight than they look like they do. The ref exists so that every action can keep []-ish dependency arrays and stay referentially stable, instead of every callback changing identity whenever the list changes. And if (idx < 0) return means a URL that no longer exists does nothing at all. The failure mode of a stale key is a no-op, not a mutation of whichever row happens to be at that position now.
The URL is mutable, but only partly
You can re-point a search: same site, different filters. The API allows that, and it checks something before it does:
if (typeof url === 'string' && url.trim()) {
// Validate that the new URL keeps the same hostname (only path/query edits allowed)
try {
const oldUrl = cfg.sites[idx].url;
const oldHost = new URL(oldUrl).hostname;
const newUrl = url.trim();
const newHost = new URL(newUrl).hostname;
if (oldHost === newHost && newUrl !== oldUrl) {
cfg.sites[idx].url = newUrl;
// The snapshot is keyed by URL, so the old baseline is now orphaned —
// drop it. The new URL re-baselines silently on its next poll.
monitor.deleteSnapshot(oldUrl);
}
} catch { /* invalid URL — ignore */ }
}
The hostname is pinned because the third identity hangs off it. A saved session belongs to a host, not to a search, so letting an edit change the host would silently move a search onto a site the app has no session for, while the row carried on showing a green sign-in state from the old one. Changing site is adding a search and deleting one, which is two deliberate actions, which is right.
And because the monitor's live state and its diff baseline are both keyed by the URL, re-pointing a search orphans both. The baseline is deleted explicitly, which is the one piece of per-search state that does not survive an edit, and the live state cleans itself up for free, because the monitor streams whole snapshots and the reducer replaces the map wholesale rather than merging into it. An entry for a URL that no longer exists simply is not in the next frame.
Three keys, three stores, one render
The store keeps them separate rather than flattening them onto the search object, which is what the previous version of this code did with an underscore-prefixed field patched from three components:
interface State {
sites: Site[];
/** Session state per host. A login belongs to the host, not to one search. */
logins: Record<string, boolean>;
/** The monitor's live state, keyed by search URL. */
runtime: Record<string, SiteRuntime>;
// ...
}
The badge a user actually reads is then computed from all three at render time:
const loginFor = (site: Site): LoginState => {
const runtime = state.runtime[site.url];
if (runtime?.state === "auth_failure") return "auth_failed";
if (runtime?.state === "blocked") return "blocked";
if (site.skipLogin) return "skipped";
const known = state.logins[hostOf(site.url)];
return known === undefined ? "unknown" : known;
};
Four sources, in priority order, with unknown as a first-class answer rather than a falsy one. Nothing is copied from one store into another, so there is no stale duplicate to go wrong, and the ordering is the product decision: a live failure outranks a saved preference, and a saved preference outranks what we last knew about the session.
One consequence I like: when a session dies, the event that says so is handled by host, not by search.
case "auth_failure":
// The session is gone for the whole host, so every search on it needs
// the login prompt, not just the one that happened to notice.
dispatch({ type: "login", host: hostOf(msg.url), loggedIn: false });
break;
One search noticed. Every search on that host is affected. Keying that state by host is the only reason the right thing happens without any extra code.
The wart
The API is not uniform. Most endpoints take an index, but two of them, retry and logout, take a URL in the body. So retry(url) and logout(url) are the two actions in the store that never call indexOf at all. Both are honest about why: a retry addresses the monitor's own state, which is keyed by URL, and a logout addresses a browser profile, which is keyed by host and derives it from the URL. But it does mean a reader of the store sees two actions that look like the others and resolve nothing, and if I ever do add ids, those two are the ones that will still not take one.
Also: almost every action ends in await refreshSites() rather than updating local state, which is a round trip through disk for a boolean toggle. Exactly one action is optimistic, and it is the one with no server-side consequences:
const setDesktopNotifications = useCallback(async (enabled: boolean) => {
dispatch({ type: "desktop_notifications", enabled });
const { ok } = await apiSetDesktopNotifications(enabled);
// Put the switch back if the write failed, rather than showing a
// preference that was never saved.
if (!ok) dispatch({ type: "desktop_notifications", enabled: !enabled });
}, []);
Re-reading after a write is slower and shorter, and in a local app where the "network" is a loopback request to a server in the same process, slower costs nothing I can measure. One optimistic update with a rollback, everywhere else the file is the truth.
The general lesson I would take out of this one: when a thing has several identities, write down which of them is allowed to change and when, because every bug in this part of the app was a key being used past the moment it was still true. Then put the translation in exactly one place, so there is one function to be suspicious of.
The app this store drives is on the download page for Mac and Windows, you get fifteen searches on a one-time licence, what each row and badge means is written out on the help page, and the per-site setup guides, including the sign-in step the hostname identity exists for, are at notifio.app/alerts, including Kamernet, Rightmove and Idealista.
Top comments (0)