When back becomes broken: how we found a tiny navigation bug

nick

nick

July 7

Bugs are everywhere. We were reminded of this recently with something very unexpected: the back button.

Here’s what happened.

First, a little background. With Granola's Mac app, you can access notes from a few different places. You can move between notes from within the app, from the tray (the menu bar granola icon, on mac), system notifications, or our "nub." (our out-of-app transcription indicator). We do this so it's easy to get to your notes, but it also introduces complexity, especially when it comes to browser and React Router histories.

Granola tray

Clicking on meetings from the tray

Recently, we encountered a surprisingly tricky bug that broke our back-navigation behaviour. A seemingly simple feature, such as the back button, can actually cause a bunch of problems when mixing navigation inside and outside the boundaries of an electron app. Here’s the deep dive into what went wrong, how we tracked it down, and what we did to fix it.

The back button stopped working, sometimes

We discovered that in some cases, the back button would stop working as expected after a series of navigations between notes involving both in-app navigations and external entry points, like using the menu bar on macOS, or if you used the nub.

Here's how the back button should work: when you're in a meeting note, clicking "back" should always take you home—or at least to a main view like Folders—not into another meeting.

We found that, instead, a handful of users were getting stuck in an odd navigation loop, jumping between meeting notes and unable to return to the app’s homepage altogether.

What made this even more confusing was that the issue only happened up after a very specific sequence of actions.

How we found this

It wasn’t happening very often. We were only getting sporadic reports from users. Luckily, one day someone on the team encountered it, so we asked them to screen record what happened. We noticed something fishy: they were able to jump from one meeting to another by clicking the transcription indicator. This meant they got stuck in an unexpected meeting-to-meeting navigation loop.

To debug this, we added client side logs to compare the window history with a locally recreated React Router history. We sent these logs to Cloudwatch so we could see what our customers were experiencing. This showed us where and how the divergence occurred, but the breakthrough came when we looked into the code and realized that combining navigation from inside and outside the Granola's Electron app was the trigger.

Reproducing: A step-by-step breakdown

We eventually pinned down a reliable reproduction path. For context, here's our electron app navigation logic:

if (window.history.length <= 1) {
  navigate("/", { replace: true });
} else {
  navigate(-1);
}

Reproduction:

  1. Start a transcription on Meeting Note C
  2. Return to the homepage
  3. Navigate to Meeting Note A from the homepage
  4. Navigate to Meeting Note B from outside the app (e.g. via the tray)
  5. Click the transcription indicator for Meeting Note C, which will take you to that meeting
  6. Press the back button – you’re taken to Meeting Note B
  7. Press the back button again – you’re taken back to Meeting Note A

At this point, you're stuck. You can’t go back to the homepage anymore. And even though the back button is still there, clicking it results in no action.

Why? Because our React Router historyStack is at length 0 while window.history.length is 3. After clearing the window history, the back button actually appended entries to the window history, as in practice, React Router was going back, but the window history was just navigating as normal. So our histories are now out of alignment and we're effectively stranded in the meeting note.

Granola back bug repro flow
Full repro flow

We confirmed all this through logging, by seeing that the window history looked healthy, but React Router history was empty. So the logic started to break down.

Fun side note: the only way to unstick yourself without quitting and reopening the app was to open a meeting note from outside the app again—resetting the history and returning functionality to the back button, as now the navigation will go to the if-clause of the above conditional.

Ok, so what’s going on?

The root of the problem came down to how we handled browser history when users navigated from outside the Electron shell—like through the tray or a notification—versus when they navigated from within the app, for example using the in-app transcription indicator.

Granola transcription indicator

The in-app transcription indicator component

When navigating from outside the app, we clear window.history, but we don’t explicitly clear React Router’s own history stack. And when the user clicks on something inside the app—like the transcription indicator—we skip that history clearing entirely.

This divergence between the two histories causes them to fall out of sync. And when you try to click “back,” the navigation is completely unexpected and the app doesn't know what to do.

Diving a little deeper…

So why did React Router navigate back only to the first meeting note—and not any earlier page like the homepage?

That’s down to how React Router manages and resets its internal stack:

  • Initial condition: React Router initially contains entries such as Home → Meeting Note A → Home → Meeting Note B.
  • External navigation impact: When users navigated externally (from the tray in this case), we cleared the browser (window.history) but did not fully reset React Router’s stack. Instead, React Router implicitly reset to a single retained entry, typically the last viewed meeting note. Which was meeting note A in this case
  • Internal navigation (the transcription indicator): Clicking the, transcription indicator then pushed new meeting notes onto React Router’s stack. However, crucially, React Router never regained earlier entries (like the homepage) since they were already discarded due to prior external navigation.
  • Resulting stack state: React Router’s stack became exclusively meeting notes, with no homepage entries remaining. Therefore, hitting the back button cycled through meeting notes until reaching the oldest remaining entry—the first meeting note visited since the external reset. At that point, React Router couldn't navigate further back

This explains precisely why React Router’s internal stack ended up isolated to meeting notes, unable to return users to the homepage.

The solution

Our first instinct was to force a replace: true navigation every time we clicked on the transcription indicator, like so:

navigate("/meeting/xyz", { replace: true });

But this had two downsides:

  1. It would always take the user back to the homepage, even if they originally navigated from a folder or another logical entry point.
  2. We’d have to remember to add this logic every time we introduced a new meeting redirect.

So we took a different approach.

Since the Electron main process has access to the full window.history, we created a new IPC channel that inspects the current history stack. When a user hits "back," we look for the first non-meeting URL and calculate how many steps to go back in order to land on it from our current history index, then navigate(-steps). This centralized logic makes back-navigation consistent, doesn’t ever require clearing the history and frees us from worrying about how a user entered a given view.

Final thoughts

This bug was a great reminder for us that mixing navigation paradigms like React Router with browser history, or internal and external routes, requires deep consideration of how state and history are managed.

Want to fix bugs like this and build tools to help humans think better?

We're hiring

nick

nick, Product Engineer