A guard against saving to the wrong brand

A tab showed one brand while the server session held another. The guard that blocks it, and a pass over 11 dashboards in a shared codebase.

Two panel tabs for different brands, one covered by a brand mismatch dialog, next to a grid of dashboards with empty states

Someone switched brands from the admin platform, landed in the panel and started working. The header showed the new brand. Most of the data on the page belonged to the old one.

Anything they saved could end up split across two brands. That kind of bug stays quiet until a brand finds another brand's content in its own campaign.

In September 2026 I put a guard in front of it. The rest of this story is what working in a big shared codebase looks like day to day, in the Revotas marketing panel, where I've made 847 commits since October 2023.

Two sources of truth for "which brand"

The panel is a React 18 app with years of history: more than 6,000 commits from over a dozen people since late 2022. I don't own the backend, and I can't change how the old endpoints decide who you are.

The brand on screen comes from a Customer record in localStorage. Most data endpoints don't read that. They resolve the brand from the server session cookie.

Usually the two agree. They split in two situations:

  • Another tab. Staff often keep several brands open. Switch the brand in tab B and the shared localStorage record changes, but tab A still shows the brand it loaded with.
  • The admin bridge. In May I wrote the bridge that hands a workspace from the new admin platform into the panel. It decodes the brand context from the URL, checks that the required fields are there, writes the Customer record and redirects to the home page with the context stripped from the URL. It updated the screen. It didn't touch the server session, which stayed on whatever brand it had before.

When I migrated customer records into the admin platform earlier that year, I'd checked with SQL that the migration hadn't caused wrong-tenant logins. This was a different path to a similar symptom, and it was in my own bridge.

Fix the bridge, then guard everything

The bridge part was two lines. Before writing the new brand, it clears localStorage and sessionStorage, so no cached state from the previous brand survives the switch.

That doesn't fix the server session, and it does nothing for two open tabs. So the real fix is a guard that runs on every page of the panel: a hook plus one dialog component, about 180 lines in total.

The hook remembers the brand the tab loaded with. It runs two checks:

  1. Tab check. Does the stored brand still match the brand this tab loaded with? It listens to the storage event, which fires in every other tab when one tab writes localStorage. So a switch in tab B reaches tab A immediately.
  2. Session check. Does the server agree? It calls the existing session-check endpoint and compares the brand id it returns with the stored one. This runs on load, and again every time the tab becomes visible.

Block the tab and ask

The obvious fix is to re-sync quietly: if the server says brand X, switch the screen to X.

There was already a version of that in the codebase, commented out in the layout file. It compared the two brands and silently logged in again as the session's brand. I deleted it in the same commit.

A silent switch is worse than the bug. If someone is halfway through a campaign for brand A and the page swaps to brand B under them, their next save is "correct" for the session and wrong for the person.

So on a mismatch the guard covers the tab with a dialog that can't be dismissed: a static backdrop, no close button, Escape disabled. It says which brand the tab shows and which brand the other side holds, and that actions are paused so nothing gets saved or sent to the wrong brand. Then it gives exactly the choices that make sense:

  • Tab mismatch: reload the page, which picks up the current brand.
  • Session mismatch: continue with the session's brand (rewrite the stored record and reload), or log out completely.

Every string went in with English and Turkish keys from the start.

One deliberate gap: if the session check itself fails (a network error, a timeout), the guard does nothing and logs the error. Blocking every tab on a flaky request would have made the panel unusable for a problem that isn't a mismatch.

One follow-up was cosmetic. The first version used a notification component with a fixed width and a single line, and long brand names made the dialog scroll sideways. I switched it to a message component the same day.

While the tab and the session hold the same brand, saves go through. When the session moves to another brand, the guard pauses the tab until someone chooses.

The dashboards pass

The guard was a data bug. The other half of my panel work is less dramatic: cleaning up code that a dozen people touched over years, without breaking their screens.

In July 2026 I did one pass over 11 dashboards, in six commits on the same day. It started with one problem. When a chart got an empty dataset, the canvas just rendered blank. You couldn't tell "no data yet" from "broken".

So I wrote a ChartContainer and a NoDataPlaceholder. The container resolves loading, then error, then empty, then the chart, in that order, in one place. "Empty" means no datasets, or every dataset holding only null, undefined or NaN. It takes a height class, so the loading skeleton is exactly as tall as the chart it replaces and nothing jumps. All 23 chart render sites go through it now.

Doing that properly meant reading every dashboard's loading logic, and that's where the real bugs were:

  • Every dashboard had a flag named loading that was set to true once the data had arrived. The JSX checked loading === false to show the spinner. Two inversions, so it rendered correctly by accident. I renamed it to isLoading with the meaning its name has, set from a finally block so it always resolves.
  • Two consent-registry dashboards passed their callback into the wrong argument slot of the shared API helper. The callback never ran, and both pages sat on a spinner forever.
  • The push dashboard threw on its error path, which also left the spinner up forever.
  • One dashboard called a setter that didn't exist, inside a request whose only job was to feed that setter. I removed the dead request.

Chart heights moved from inline styles to CSS classes, which surfaced contradictions like a maxHeight of 430px under a minHeight of 450px. Section titles, which were a mix of spans, an h1 and styled labels, became h3 everywhere, at the same rendered size.

And I added 114 translation keys in each language. Before that, only the email dashboard read its text from the translation files. The admin dashboard stayed out on purpose: it's internal-only and nothing in it uses translations.

Smaller habits that kept it reviewable

Two earlier pieces of work set up how I approach this codebase.

In January 2026 I split the monolithic route file into feature modules for email, personal search, recommendations, smart widgets and web push. The main app file shrank by about 650 lines. Later features became small diffs inside one module instead of edits to a file everyone touched.

And I fix crashes reported by error monitoring as narrowly as I can. In August, three crash reports turned into one commit that touched only the six files involved.

What I'd do differently

The guard catches a mismatch on load, on visibility and on a storage change. A session that changes some other way while the tab stays in front would only be caught on the next check. The honest fix is on the backend: endpoints that take the brand explicitly and refuse a mismatch. The UI guard is where I could act, and where the bug was visible.

And the app push dashboard. I built it in July 2025 with line and pie charts, stat tiles, a campaign table and a 7 × 24 send-time heatmap. It was built against static sample data, and a year later it still was, which is why I left it out of the empty-state pass. Next time I'd get one real data source wired before polishing the layout.

Try the interactive demo. Switch the brand in one tab and try to save in the other. More of my work is on the Revotas page.

Have a problem worth solving?Let's make it reliable

Start a conversation

Open to full-stack and applied AI roles, remote or hybrid.

Tell me about the role, the team and the problem.