GalleryExhibit 05

The other tab brought the account back

Deleting an account in one tab made a second tab notice the missing profile and recreate it.

Multi-tab account flowConcurrencyBrowserAsyncReactRealtime listenersServer functionsVitest

An app whose auth layer subscribes to the signed-in user’s document and repairs it when fields are missing.

What you can check here. Three scripted sequences you can step through here, with the invariants that separate them asserted in the unit tests. A model of the ordering, not a running backend.

The object

See it happen

Exhibit 05 — BrokenMulti-tab account flow
Step 1 of 8

Tab B

listener attached to users/{uid}

The feed tab has been open for an hour, listening to the same user document as everything else.

Tab ASettings

  • signed inyes
  • profilecasey
  • caches held3/3
  • repair calls0

Tab BFeed

  • signed inyes
  • profilecasey
  • caches held3/3
  • repair calls0

Serverdatabase

  • users/{uid}live
  • username reservationheld
  • tombstonenone
accountlive
repair calls0

There is no server here. The two tabs, the listener and the cascade are a scripted model of the sequence, stepped by hand so you can stop between any two events. The frames are hand-written; the tests assert the invariants at the end of each one. Only Tab B’s repair attempts are shown; the tab that started the deletion ran the same listener.

What you are looking at

The passive tab sees the profile vanish, assumes damage, and rebuilds it.

  • Step through the deletion in Tab A and watch Tab B’s listener react.
  • Watch the user document and the username reservation come back.
  • Notice that Tab B is still signed in — now to a rebuilt profile for an account the backend deleted.

Wall text

What happened

The auth layer subscribed to the signed-in user’s document with a realtime listener. If the document was missing, or was missing a display name or an avatar, it called a repair routine that recreated it — a sensible guard against a half-finished signup.

Deleting an account ran a server-side cascade: remove the user document, the username reservation, and everything the account owned. Every open tab was watching that document, including a feed tab left open in the background.

That tab’s listener fired with a missing document, decided the profile was damaged, and called the repair endpoint. The account came back — with its username reservation — moments after the backend had removed it. The tab that started the deletion ran the same listener and could do the same.

The first fix stopped the resurrection. Reviewing it turned up two smaller problems in the same handler, which is why this exhibit has three states rather than two.

Root cause

“The document is missing” has two meanings and the code only knew one. A missing profile after signup is damage worth repairing; a missing profile during deletion is the intended outcome. The listener could not tell them apart, so it treated a successful delete as corruption.

The fix gives the client a way to know. Before the deletion starts, the deletion flow adds the uid to a module-level set. The cascade then sets pendingDeletion: true on the document before removing it, so a tab that did not click Delete still sees the intent, through the same stream it is already listening to.

The client guard alone would not be enough. A stale client or a not-yet-expired token can call the profile endpoints directly, so the server writes a tombstone record before removing the user document, and the create and update endpoints refuse while a tombstone exists. The tombstone is the authoritative guard; the in-memory flag just closes the same-tab window without an extra read.

Why the first fix was not the end of it

The first fix caught the obvious path — the snapshot where the document does not exist — and missed the one just before it.

The cascade blanks fields on the way down, so for a moment the document still exists with an empty display name. That is the other repair trigger, and it fired a call into a profile that was being torn down. The first fix noted pendingDeletion: true when it arrived but carried on into the repair check; the complete version returns early the moment it sees the flag.

The second gap was quieter. The passive tab signed out correctly, but signing out through this branch skipped the cache clearing the normal sign-out path does, so the tab kept a profile, a content list and a user cache belonging to an account that no longer existed. Mirroring those three calls was the last change on the branch.

The change

Code

The missing-document branch, after both fixesminimal reproduction
const stop = onSnapshot(profileRef, (snap) => {
  if (!snap.exists()) {
    // Gone because we are deleting it: leave, and never rebuild.
    // Rebuilding here is what brought the account back.
    if (pendingSeenRef.current || deletingNow(user.uid)) {
      setProfile(null);
      forgetProfile();
      forgetContent();
      forgetUsers();
      void signOut(auth);
      return;
    }
    rebuildProfile(user); // a half-finished signup: repair it
    return;
  }
  // …the document exists: see the next excerpt
First fix to final fix: stop before the repair checkminimal reproduction
   const data = snap.data();
   if (data.pendingDeletion === true) {
-    pendingSeenRef.current = true; // remembered for the branch above
+    // Being torn down: remember it, show what is there, never repair.
+    pendingSeenRef.current = true;
+    setProfile({ id: snap.id, ...data });
+    return;
   }
   const incomplete = !data.displayName?.trim() || !data.avatarUrl?.trim();
   if (incomplete) rebuildProfile(user);
The authoritative guard, on the serverminimal reproduction
// Profile writes look for a deletion marker first. The cascade writes
// the marker before it removes anything, so an old tab or a token that
// has not expired yet cannot rebuild the account halfway through.
async function refuseIfDeleting(uid: string): Promise<void> {
  const marker = await db.collection("tombstones").doc(uid).get();
  if (marker.exists) {
    throw new Error("account is being deleted");
  }
}

The test that catches it

Proof it stays fixed

The failure that remains once the client is fixed is an old tab running yesterday’s bundle, which no client-side test can reach. So the real-world guard is layered — a tombstone written before the delete, both write endpoints checking it, and database security rules that keep clients away from the tombstones — rather than a single assertion.

What the museum can pin is the sequence. lib/sims/two-tabs.ts holds the three versions as scripted frames: server state, both tabs, and what each step does. The tests then assert the invariants that distinguish them, which is stricter than it sounds, because most of the interesting properties are negative.

The broken script must end with the document recreated and the reservation held; the first-fix script must end deleted and signed out but still holding caches; the fixed script must end with zero repair calls and zero caches. One case checks the ordering that makes the server guard work at all — the tombstone has to be written before the document is removed, in every version that has one.

This is a scripted model of a sequence, not a running backend, and the display case says so.

tests/unit/sims/two-tabs.test.tsfrom this repository · source
it("ends with the account resurrected in the broken version", () => {
  const end = last(TAB_SCRIPTS.broken);
  expect(end?.server.userDoc).toBe("recreated");
  expect(end?.server.usernameReservation).toBe(true);
  expect(end?.tabB.repairCalls).toBeGreaterThan(0);
});
 
it("writes the tombstone before the document is removed", () => {
  for (const version of ["first-fix", "fixed"] as const) {
    const frames = TAB_SCRIPTS[version];
    const tombstoneAt = frames.findIndex((f) => f.server.tombstone);
    const deletedAt = frames.findIndex((f) => f.server.userDoc === "deleted");
    expect(tombstoneAt).toBeGreaterThanOrEqual(0);
    expect(tombstoneAt).toBeLessThan(deletedAt);
  }
});

How it went

Discovery to regression test

  1. Discovered

    Deleted accounts came back

    Found while auditing the deletion cascade: the listener’s profile-repair path treats a deleted document as a damaged one. Step through the Broken state to watch it happen.

    The simulation ↗
  2. First fix

    Suppress the repair, and refuse it on the server

    A module-level in-progress flag plus a pendingDeletion field stop the client; a tombstone written before the delete stops everyone else. The passive tab signs out instead of repairing.

  3. Final fix

    Two gaps found in review

    Return early on pendingDeletion so the blanked-display-name path cannot fire a repair, and clear the profile, content and user caches when signing out through the deletion branch.

    lib/sims/two-tabs.ts ↗
  4. Pinned by a test

    Guarded in three places rather than tested in one

    The client that has to be stopped is an old one, so the guard is layered rather than asserted. The museum pins the sequence instead: invariants over the three scripts, including the tombstone ordering.

    tests/unit/sims/two-tabs.test.ts ↗

Verify it yourself

Sources