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.