유대선
프로젝트로
·트러블슈팅·3

The Lab 'remembered' each session — until you closed it

Per-session state in the Lab drawer was supposed to survive switching between terminal panes. Dogfooding the live app revealed it vanished on every close — the drawer was mounted conditionally, so closing it unmounted the component and wiped the in-memory snapshot store. The internal `if (!open) return null` guard was dead code.

I shipped a per-session feature for the Lab drawer: each terminal pane keeps its own in-progress "rep" (prediction, review notes, verdicts), so switching from session A to session B and back restores exactly what you'd left in each. 384 unit tests passed. The snapshot/restore logic — a useRef<Record<paneId, LabRepState>> plus a useEffect keyed on activePaneId — read correctly in isolation.

Then I actually opened the running app to check, instead of trusting the green tests.

What I saw

In pane A's Lab I typed a marker into the "문제 한 줄" field. Closed the drawer. Clicked into pane B — its Lab opened empty, which looked right. Clicked back into pane A, reopened the Lab. The session bar correctly read 세션 #3mnc… and the field was empty. My work was gone.

The feature's headline promise — A↔B preserved — failed the very first time I exercised it the way a user would: close, switch, reopen.

The cause

The snapshot effect wasn't wrong. The mount wiring was. In App.tsx:

{showLab ? (
  <Suspense fallback={null}>
    <Lab open={true} onClose={() => setShowLab(false)} />
  </Suspense>
) : null}

Closing the drawer sets showLab = false, which unmounts <Lab>. Unmounting destroys the component's useRef — the very store holding every pane's snapshot. So the per-session memory only survived a pane-switch that happened while the drawer stayed open; any close wiped all of it.

The cruel part: the component already had if (!open) return null inside it — the design assumed the Lab stayed mounted and just rendered nothing when closed. But open was hardcoded to true, so that guard was dead code. The parent never gave it a chance to do its job.

The fix

Keep the Lab mounted while you're in a workspace; let the prop drive visibility:

<Suspense fallback={null}>
  <Lab open={showLab} onClose={() => setShowLab(false)} />
</Suspense>

Now if (!open) return null finally means something, the stash ref survives close and reopen, and the [activePaneId] effect tracks pane switches even while the drawer is closed — so reopening shows whatever that session had. No change to the Lab component itself; the bug was entirely in how the parent mounted it.

Re-ran it live: typed BETAMARK in pane A → closed → pane B opened empty (#zb5l) → reopened pane A → field restored to BETAMARK (#3mnc). tsc clean, 384 tests, build green.

The lesson

State stored in a component's useRef/useState is only as durable as that component's mount lifetime. The moment a parent mounts it conditionally — {flag && <X/>} — every "close" is an unmount that silently drops whatever you thought you were persisting. A return null guard inside the child is a promise the parent has to keep.

So decide it out loud: either keep the thing mounted and gate it with a prop, or lift the state above the unmount boundary (a parent ref, a module store, real storage). Don't let a useRef quietly straddle a mount boundary and call it "persisted."

And the meta-lesson, again: dogfooding caught what 384 unit tests couldn't. The logic was correct; the bug lived in the wiring no test touched. Opening the actual app and doing the boring user motion — type, close, switch, reopen — found it in thirty seconds.