Notifio watches rental search pages and emails you when a new listing appears. With the auto-reply upgrade it can also answer the listing on your behalf, by replaying a flow you demonstrated once on that site yourself.
That feature has a setup screen: one row per saved search, each row showing where that site stands. A row needs two things before it can reply. A recording, and a live login on that site, because the reply goes out as the user's own account.
The recording is on disk, so it is known instantly. The login is not. Finding out means asking the main process to open that site's browser profile and look, which is slow enough that you cannot block the list on it.
So the list renders before the answer arrives, and that produces a third state that nothing in the data model has a name for.
Two setStates, one load
async function load() {
const [recipeRes, siteRes] = await Promise.all([getRecipes(), getSites()]);
const recipeList = recipeRes.data?.recipes ?? [];
if (recipeRes.data?.recipes) {
setRecipes(recipeList);
setRecording(recipeRes.data.recording);
setAvailable(recipeRes.data.available);
}
const siteList = Array.isArray(siteRes.data) ? siteRes.data : [];
setSites(siteList);
const withLogin = await withLoginState(siteList);
setSites(withLogin);
onReadyCountChange?.({
ready: withLogin.filter(
(s) => s.loggedIn === true && recipeFor(recipeList, s) !== null
).length,
total: withLogin.length,
needLogin: withLogin.filter((s) => s.loggedIn !== true).length,
});
}
setSites twice. The first call puts the rows on screen with names, URLs, reply modes and recordings, all of which are already known. The second call puts the same rows back with one extra field attached.
Collapsing this into a single setSites(withLogin) at the end would be tidier and would be worse. The login probes take as long as they take, and a list that shows nothing until the slowest profile has been opened is a list that looks broken for a second on a cold start. Two renders is the price of showing what you know when you know it.
The counts callback deliberately fires only once, from the second pass. The parent tab uses it for a "4 of 7 ready" summary, and a summary that counts rows nobody has looked up yet would be wrong in a way the user can see.
The type that carries the third state
/**
* A search plus whether this tab has confirmed a login for it. Local to the
* auto-reply tab: it needs one boolean per row and nothing else, and keeping it
* here means it cannot drift into the monitor's own idea of a session.
*/
interface SetupSite extends Site {
loggedIn?: boolean;
}
loggedIn?: boolean is three states wearing two states' clothes: true, false, and absent. The component knows this and unpacks it explicitly at the top of the map:
// 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;
One optional boolean in, two booleans out, and every consumer on the row takes both. This is the part of the file I think is right. The alternative, a LoginState union with a "checking" member, would be more honest in the type and would also mean inventing a value that exists purely to describe the absence of a value, in a field that is set exactly once, two lines after it is created. Deriving the pair at the use site keeps the wire format as "the field is there or it is not".
The status line reads both and leads with the unknown case:
function statusLine(recipe, on, loggedIn, loginChecked): string {
if (!loginChecked) return "Checking your login…";
// The login comes before everything else it could say, because nothing else on
// this row can happen without it, not the setup, and not a reply.
if (!loggedIn) {
if (!recipe) return "Log in to this site to set it up. Listings are emailed to you meanwhile.";
...
}
...
}
And the action button renders nothing at all until the answer is in:
{!loginChecked ? null : !loggedIn ? (
<button onClick={() => setLoginFor(site)}>Log in</button>
) : !recipe ? (
...
)}
Both correct. Three states in, three behaviours out.
The badge forgot
function SetupBadge({ recipe, loggedIn, loginChecked }) {
// The badge names the step the row is waiting on, and while signed out that is
// the login whatever else is or isn't done: no session, no setup and no reply.
if (loginChecked && !loggedIn) {
return <span className={...}>Login needed</span>;
}
if (!recipe) {
return <span className={...}>Not set up</span>;
}
if (recipe.status === "broken") {
return <span className={...}>Site changed</span>;
}
if (recipe.status === "verified" && loggedIn) {
return <span className={...}>Ready</span>;
}
return <span className={...}>Set up</span>;
}
It takes loginChecked, and it uses it in exactly one place: to avoid claiming "Login needed" before it knows. That guard is correct and it is also the only one. There is no if (!loginChecked) return <Checking /> at the top, so during the in-flight window the function falls through to whatever the recording alone implies.
Which means a row with a verified recording, mid-check, renders like this:
Kamernet, Leiden
Checking your login... [ Set up ]
The status line says the answer is not known. The badge, six pixels to its right, is already reporting one. recipe.status === "verified" && loggedIn is false because loggedIn is false because the field is undefined, so it drops to the final return, which is the "recorded but nothing confirmed sent" badge. A real state, shown at a moment when we have no idea whether it is the right one.
Nobody has reported this and I doubt anybody could. The window is one round trip, the two strings do not contradict each other loudly, and the badge settles to the truth a moment later. It is a bug in the sense that the component had the information and did not use it, which is the kind I find most worth writing down, because the cause is not ignorance of the edge case. The parameter is right there in the signature. Somebody threaded loginChecked into this function specifically for the unknown state and then handled one branch of it.
The fix is four lines and an extra grey pill. What I would rather do is stop passing two booleans to three different functions and let the row compute one rowState value once, so that adding a state means updating one switch instead of remembering three call sites. That is the actual lesson: deriving a pair of booleans at the use site is fine when there is one use site, and this row has three.
Two more things in the same function
withLoginState probes once per host, not once per search:
/**
* Attach each search's login state, one lookup per host.
*
* `skipLogin` is about scraping a public results page and buys nothing here,
* so those searches are reported as signed out rather than exempt.
*/
async function withLoginState(list: Site[]): Promise<SetupSite[]> {
const hosts = [...new Set(list.map((s) => hostOf(s.url)).filter(Boolean))];
const results = await Promise.all(
hosts.map(async (host) => {
const site = list.find((s) => hostOf(s.url) === host);
if (!site) return [host, false] as const;
try {
const { data } = await getLoginStatus(site.url);
return [host, data.loggedIn === true] as const;
} catch {
return [host, false] as const;
}
})
);
const byHost = new Map(results);
return list.map((s) => ({ ...s, loggedIn: byHost.get(hostOf(s.url)) ?? false }));
}
A session belongs to a host, so three searches on one portal are one probe, and the deduplication is what makes the two-render pattern cheap enough to repeat.
And it repeats quite a lot, because while a recording is open the component polls:
// While a recording is open, keep the captured-action count live.
useEffect(() => {
if (!recording.recording) return;
const timer = setInterval(() => void load(), 2000);
return () => clearInterval(timer);
}, [recording.recording]);
Every two seconds, for as long as the user is demonstrating a reply, the whole load() runs: two API calls, then one login probe per distinct host, then two setSites. That interval exists to keep a single number on screen fresh, the count of actions captured so far. It is re-fetching everything to animate one integer.
Also note the ?? false in the last line. A probe that threw and a probe that came back negative produce the same value, so a browser profile that failed to open reads as "signed out" and the row offers a login the user does not need. Three states at the top of the function, two by the bottom of it, which is the same shape as the badge bug one level down.
If you want to see what the thing being set up actually does, there is a guide on writing the first message to a landlord, which is the message the recording ends up replaying.
Top comments (0)