The Key One Level Too Deep

The Key One Level Too Deep

The problem

Open the Journal tab, wait for it to load, switch to Chat, switch back. For a full beat, Journal says "No journal yet" — then the data you already fetched twenty seconds ago reappears. Do it again. Same thing. Every single time.

The device log made it worse, not better. Every tab switch was re-firing the entire load sequence — /jrn/search, /vault/documents, /vault/pets/.../profile, /vault/pets/.../memory — as if the tab had never been opened before. Vault did the same thing. Whatever was wrong wasn't a caching miss or a slow request. Something was throwing the loaded state away and starting over, on a tab switch that should have cost nothing.

Where it actually was

Journal, Chat, and Profile aren't three separate screens stacked behind a router. They live inside one shared component, AppWorkspaceShell, that renders all three as a sliding 3-panel window — each panel a Positioned widget inside a Stack, shifted sideways as you drag. It's the kind of component built once and reused everywhere, which is exactly why a bug in it doesn't look like a bug in any one feature. Journal wasn't broken. Chat wasn't broken. The row they both live inside was.

Flutter decides whether to reuse a widget's state or throw it away and rebuild by comparing keys — but only among a widget's direct children. Stack reconciles its children list one level deep: it looks at each Positioned and its key, nothing underneath. In this shell, the key that identified "this is the Journal panel" wasn't on the Positioned itself. It was one level further down, on a KeyedSubtree wrapping the actual content.

That one level was the whole bug. An unkeyed Positioned gets matched by position in the list, not by searching for a matching key somewhere inside it. As you swipe, a panel's position in that list changes — Journal slides from slot 0 to slot -1 as Chat becomes active. Flutter looked at "whatever is in slot 0 now" versus "whatever was in slot 0 before," saw two different Positioned widgets with two different unkeyed identities, and did the only safe thing it could: tore the whole subtree down and rebuilt it from nothing. New State. New ViewModel. Every network call, again. It did this for every panel, on every swipe, even for panels that never left the visible window.

The key was real. It was just guarding a widget that nothing was checking.

The fix, and the trap next to it

The fix was one line moved: put the key on the Positioned, not on the KeyedSubtree inside it. With the key on the widget Stack actually compares, Flutter's normal keyed-list reconciliation takes over — it can now see "this key existed in the old list, just at a different index" and relocate the panel instead of discarding it. State survives. Nothing refetches.

The part worth sitting with is what the key should actually contain, because getting it wrong in the other direction reintroduces a different bug that had already been fixed once. With three or more panels — the real journal/chat/profile row — the three visible slots always land on three distinct panels, so a panel's own id is a safe, unique key on its own. But the same shell also renders things like a 1- or 2-item pet carousel, where the sliding window can show the same panel in two slots at once. There, id alone collides, and the slot number has to stay part of the key to keep two simultaneous appearances of the same panel from being treated as one. Drop the slot there and you get back the earlier bug this component already shipped a fix for: a per-pet chat panel stuck showing the previous pet after a switch. The correct key isn't "the id" or "the slot" — it's whichever one actually disambiguates, and that depends on how many panels are in the row.

How it was verified, not just believed

The regression test doesn't assert on network calls or loading spinners — it counts State creations directly. Mount a 3-panel row, script the swipe sequence Journal → Chat → Profile → back to Journal, and count how many times Journal's State object was constructed.

Before the fix: 4. Every swipe was a new mount.
After the fix: 1. The state created on first open survived every swipe after it.

That number is the whole bug, compressed to one integer. A second, pre-existing test was re-run alongside it to make sure the fix didn't swing back the other way — the per-pet chat panel still correctly remounts when you switch pets, because that behavior depends on the same key carrying the panel's identity, not just its slot.

One smaller, unrelated thing surfaced in the same investigation: Journal's loading state and its truly-empty state were the same code branch, so even a first-ever open — no remount involved — flashed "No journal yet" before data arrived. That got its own fix (a real loading skeleton, matching the pattern already used on the day screen), separate from the key fix, because a component correctly keeping its state and a component correctly displaying its state are two different guarantees, and this bug happened to expose both at once.

Key takeaways

A key only means something to the widget whose direct parent is doing the comparing. Stack never looks inside its children to find an identity marker — it looks at the child itself. A key one level too deep is functionally no key at all, and the failure it produces (full teardown, not a subtle glitch) looks nothing like "I forgot a key somewhere," which is what makes it slow to diagnose.

The right key isn't a fixed formula, it's whatever is unique in your actual cardinality. Three-or-more panels never repeat, so identity alone is enough. Fewer than three can repeat by construction, so identity alone silently merges two distinct appearances into one. The same component needed two different answers depending on how many panels it was holding, and getting only one of those cases right had already produced a real, previously-shipped bug.

"Fixed" means a test that counts the thing that was wrong, not a screen that looks right once. A mount count of 1 instead of 4 is falsifiable in a way that "seems fine now" is not — and it's the only reason the fix could be checked back against the other bug this same file had already solved, instead of quietly reopening it.

Author's note

What stuck with me wasn't the fix, it was how confidently wrong the original code looked. There was a key, right there, with a comment explaining exactly why it existed. It just wasn't attached to anything that mattered. That's a specific kind of bug worth being afraid of — not the missing safeguard, but the one that's present, reads correctly, and is silently checked by nothing at all.

Read more