Skip to content
Development

Fixing a race, an Esc-key bug, and a duplicate-key crash in Greenhouse

By Victor Da Luz
sveltetauriaccessibilitydev-loggreenhouse

A batch of robustness and accessibility fixes for Greenhouse, my Tauri desktop app for managing creative projects. Nothing here is glamorous - it’s the kind of cleanup pass every app needs after the first few features land and you start noticing the edges.

The race nobody would have caught by reading the code once

Greenhouse has a “refresh the day” function that re-pulls state from the Rust backend after almost any action - capturing an idea, vaulting a project, logging a touch. It’s called refreshDay, and it’s dead simple: call the backend, get the fresh state, assign it.

The problem showed up when I looked closely at what happens when you vault a project from its detail view. That action calls two things back to back: an onChanged callback and an onClose callback, and both of them call refreshDay. So two requests for the day’s state go out nearly simultaneously. Nothing guarantees they come back in the order they were sent.

If the first one (issued earlier, so carrying slightly staler data) happens to resolve after the second one, its stale response overwrites the fresh one - and a project you just vaulted can pop right back into view. Vault it again and you’d double-stamp the vault decision.

The fix is a pattern I’d used before but never needed here: a monotonic sequence counter. Every call to refreshDay increments a counter and remembers its own number. When the response comes back, it only gets applied if its number still matches the latest one issued. Late, stale responses just get dropped on the floor.

let refreshSeq = 0;
async function refreshDay() {
  const seq = ++refreshSeq;
  try {
    const next = await getDailyState();
    if (seq === refreshSeq) daily = next;
  } catch {
    // Keep showing the day we already have.
  }
}

What made this satisfying (and a little humbling) was writing a regression test for it and having my first version pass even with the fix temporarily removed. The test was checking a condition that was already true before either racing call had resolved, so it wasn’t actually testing anything. I only caught it because I got in the habit of deliberately breaking the fix and re-running the test - if a “regression test” can’t fail, it isn’t one.

Escape key, meet disabled button

Every dialog in Greenhouse disables its Cancel button while a submit is in flight, so you can’t fire a second request while the first is still running. Reasonable enough - except native <dialog> elements respond to the Escape key independently of any button, and I hadn’t accounted for that. Press Escape while a capture is saving and the dialog closes anyway, the disabled Cancel button notwithstanding, and any state that was supposed to update on completion (streak count, worklist) just never happens because the callback never fires.

The dialog element actually fires a cancel event before it closes, and that event is cancelable:

<dialog
  oncancel={(e) => {
    if (busy) e.preventDefault();
  }}
>

Simple once I found it, easy to miss until someone (or some test) actually mashes Escape mid-submit.

A duplicate key that only bites at exactly the wrong moment

Touch history in the project detail view was keyed by timestamp, at one-second resolution. Log a touch, then immediately advance the project’s stage (which also logs a touch) within the same second, and you get two list entries with an identical key. Svelte doesn’t like that - the detail view would crash. The fix was to just key by list index instead of timestamp, which is what the key should have been in the first place since these are ordered, append-only entries.

Accessibility odds and ends

A few smaller a11y items came out of an earlier review: a zone heading’s id was built from the zone’s title text directly, which broke for “Ripe today” because ARIA ids can’t contain spaces. Slugified it. The item count next to each zone heading had aria-hidden on it, so screen reader users never heard how many items were in a zone - dropped that. And the capture dialog’s “success” view swap moved no focus anywhere, so a screen reader user would be left focused on a form field that no longer existed - now it focuses the confirmation heading, and refocuses the name field if you click “Capture another.”

Verifying it, and hitting a wall in a fun way

Greenhouse has a WebDriver-based setup for driving the actual built app (not a mocked test) - real clicks, real IPC round trips. I used it to confirm the focus and ARIA fixes for real in the browser, which was satisfying since jsdom can only approximate that.

I tried to use the same setup to send a real Escape keypress and verify the dialog-cancel fix end-to-end. It didn’t work - and rather than assume my code was wrong, I wrote a tiny probe script that just sent a printable character into a focused text field and checked whether it showed up. It didn’t. Turned out the WebDriver plugin’s /actions endpoint doesn’t actually deliver key events to the webview yet in the version I’m on. Good reminder to isolate “is my tooling broken” from “is my code broken” before chasing a fix that isn’t needed - the Esc-guard logic is still fully covered by a unit test that dispatches a real DOM cancel event and checks preventDefault was called, just not proven against a literal OS keypress yet.

Related reading

Development

Toast + undo for Greenhouse's vault action

The app's first transient-feedback surface, the aria-live rule that makes a conditionally-rendered toast silently unannounced, and a prop that goes stale the moment you rename.

Read