DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two processes count the same three numbers, and the renderer's shortcut turns rightmove.co.uk into co.uk

Notifio is a desktop app that watches rental search pages and emails you when something new appears, with an optional feature that fills in the contact form for you. The tab for that feature has a master switch, and the switch is disabled until at least one of your searches is ready.

"Ready" is three numbers: how many searches you have, how many can actually reply, and how many are signed out. They decide whether a checkbox is clickable and which sentence sits under it:

{!sitesOk
  ? siteCount === 0
    ? "Add a search on the Monitor tab first."
    : sitesNeedLogin > 0
    ? `Log in to ${sitesNeedLogin === 1 ? "the site" : `${sitesNeedLogin} sites`} below first...`
    : "Set up at least one site below first."
  : on
  ? "Running. New listings get a reply within about a minute."
  : "Turn this on and Notifio replies on your behalf, no confirmation needed."}
Enter fullscreen mode Exit fullscreen mode

Those three numbers are computed twice, in two processes, by two different pieces of code. This post is about why, and about the bug that arrangement was hiding, which I found while writing the post rather than while writing the code.

Copy one: the main process

The app's UI is a renderer talking to an Express server inside the Electron main process, and GET /api/autoreply answers with the switch state plus the counts:

const loginByHost = new Map<string, boolean>();
for (const site of sites) {
  let host: string;
  try { host = new URL(site.url).hostname; } catch { continue; }
  if (loginByHost.has(host)) continue;
  try {
    loginByHost.set(host, await checkLoginStatus(site.url));
  } catch {
    loginByHost.set(host, false);
  }
}

const sitesReady = sites.filter((s) => {
  const domain = registrableDomain(s.url);
  return domain !== null && domainsWithRecipes.has(domain) && isLoggedIn(s.url);
}).length;
const sitesNeedLogin = sites.filter((s) => !isLoggedIn(s.url)).length;
Enter fullscreen mode Exit fullscreen mode

The Map is there because a session belongs to a host, not to a search. Three Kamernet searches share one login, so checking it three times is two wasted round trips to a browser profile on disk. registrableDomain is the strict version, built on tldts, and it has its own post about one of its options.

Copy two: the renderer

The list of searches is its own component, and it independently works out the same three numbers and hands them to its parent:

onReadyCountChange?.({
  ready: withLogin.filter(
    (s) => s.loggedIn === true && recipeFor(recipeList, s) !== null
  ).length,
  total: withLogin.length,
  needLogin: withLogin.filter((s) => s.loggedIn !== true).length,
});
Enter fullscreen mode Exit fullscreen mode

The parent takes them at face value and overwrites what the server told it:

onReadyCountChange={({ ready, total, needLogin }) => {
  setSitesReady(ready);
  setSiteCount(total);
  setSitesNeedLogin(needLogin);
}}
Enter fullscreen mode Exit fullscreen mode

Its login lookup does the same host dedupe, written as a fan-out rather than a loop:

const hosts = [...new Set(list.map((s) => hostOf(s.url)).filter(Boolean))];
const results = await Promise.all(hosts.map(async (host) => { ... }));
const byHost = new Map(results);
return list.map((s) => ({ ...s, loggedIn: byHost.get(hostOf(s.url)) ?? false }));
Enter fullscreen mode Exit fullscreen mode

Why two copies is the right answer here

The duplication looks like something to delete, and for two years of my career I would have deleted it. The two reasons it stays:

The parent needs the numbers before the child exists. One request gives the panel enough to decide which of two completely different screens to draw, and the comment in the code is about exactly that:

// Until the first fetch lands we do not know which of the two pages to draw,
// and guessing would flash the upgrade card at people who already paid.
Enter fullscreen mode Exit fullscreen mode

The child learns things the server has not been asked about yet. Logging in to a site happens inside this tab, in a window the user drives themselves. The moment they come back and confirm, the child knows one more search can reply. Re-fetching /api/autoreply to find that out means a second round of login checks across every host just to learn a fact the component is already holding. So it reports upward instead, and the switch enables itself the instant the modal closes.

The honest framing is not "two sources of truth". It is one source of truth, the main process, plus an optimistic local update of a derived number, with the server's answer as the value it starts from. That is a normal pattern. The thing that makes it dangerous is when the two calculations are not actually the same calculation.

The bug

Here is the renderer's domain helper:

function domainOf(url: string): string | null {
  try {
    const host = new URL(url).hostname.replace(/^www\./, "");
    const parts = host.split(".");
    // Good enough for grouping in the list; the main process does the strict check.
    return parts.length > 2 ? parts.slice(-2).join(".") : host;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Take the last two labels of anything with three or more. That is correct for www.kamernet.nl, and it is wrong for every site I support in the UK:

www.rightmove.co.uk  -> co.uk
www.spareroom.co.uk  -> co.uk
www.zoopla.co.uk     -> co.uk
www.openrent.co.uk   -> co.uk
Enter fullscreen mode Exit fullscreen mode

The comment says the shortcut is good enough for grouping, and if the value were only ever compared against itself, it would be. It is not. It is compared against a value produced by the strict function:

function recipeFor(list: Recipe[], site: Site): Recipe | null {
  const domain = domainOf(site.url);
  if (!domain) return null;
  return list.find((r) => r.domain === domain) ?? null;
}
Enter fullscreen mode Exit fullscreen mode

A saved recording stores its domain as the eTLD+1 from tldts:

/** Registrable domain (eTLD+1) this recipe applies to. */
domain: string;
Enter fullscreen mode Exit fullscreen mode

So for a Rightmove or SpareRoom search, the recording's domain is rightmove.co.uk, the row asks for co.uk, and find returns nothing. The row renders "Not set up" and a Set up button for a site that is set up. ready counts zero where it should count one, that zero is pushed to the parent, and the parent dims the master switch that the server had just enabled. The symptom is "auto-reply refuses to turn on for my UK searches", and nothing on screen points at a string-splitting helper.

Two more things make it worse than it looks. First, co.uk is one bucket, so if two .co.uk recordings ever did match, they would match each other. Second, the strict and loose values disagree in only one direction, which means the Dutch sites that most users start with (Kamernet, Pararius, Funda) all work, so the shortcut looks fine right up until somebody searches in London.

The rule I actually want from this

The comment was not wrong about its own intent. It was wrong about the blast radius, and the general form of that mistake is worth keeping:

A deliberately loose version of a value is safe only while its output is never compared against the strict version's output. The moment it is, "good enough" means "agrees with the precise one on every input you will ever see", which is a much stronger claim and almost never true of a string split.

The fix is not a better heuristic in the renderer. A second implementation of public suffix rules is the same bug with more lines. The options worth taking are to send the match down rather than recomputing it, since /api/recipes and /api/sites are answered by the process that already holds the strict function, or to have the renderer group by hostname, which it already computes correctly for the login lookup and which cannot silently collide.

Either way the component stops knowing what a domain is, which is the correct amount for a list of rows to know.

The part of this that is already right

The same component gets a different three-valued question exactly right. Its login flag is optional, and undefined means "still checking":

// undefined while the first check is still in flight — not the same as
// signed out, so the row waits rather than accusing them of being out.
const loggedIn = site.loggedIn === true;
const loginChecked = site.loggedIn !== undefined;
Enter fullscreen mode Exit fullscreen mode

Two booleans out of one optional, and the row shows "Checking your login" rather than "Login needed" during the fetch. The sentence that follows from them has its own post, because the ordering of those checks is most of the UI design in that tab.

That is the same class of problem as the domain bug: a value with three states being read as if it had two. In one case the component was careful. In the other it trusted a comment that described an intention rather than a guarantee.

The app is at notifio.app/download, the feature is a one-time upgrade described on notifio.app/pricing, and the UK sites that found this bug for me are listed on notifio.app/alerts.

Top comments (0)