@@ -19,9 +19,10 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
1919 * and the navigator hid `open` inside navigation state — the close
2020 * animation could only be observed via parent-navigator event listeners
2121 * with no queryable "is animating" (which forced a safety timeout in the
22- * session screen's mount gate). As plain React state + direct
23- * onTransitionStart/End props, the gate is exact. Visuals are identical —
24- * the navigator was a thin wrapper around this same component.
22+ * session screen's mount gate). As a plain React `closing` flag (set in
23+ * closeDrawer, cleared on the drawer's onTransitionEnd) the gate is exact.
24+ * Visuals are identical — the navigator was a thin wrapper around this
25+ * same component.
2526 *
2627 * Sole child is the `(stack)` group (see ./(stack)/_layout.tsx) — its
2728 * inner Stack contains both the new-session page (`index` → URL `/`) and
@@ -49,40 +50,46 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
4950export default function DrawerLayout ( ) {
5051 const scheme = useColorScheme ( ) ?? "light" ;
5152 const [ open , setOpen ] = useState ( false ) ;
52- const [ transitioning , setTransitioning ] = useState ( false ) ;
53+ const [ closing , setClosing ] = useState ( false ) ;
5354
55+ // `closing` gates the session screen's ChatPanel mount against the close
56+ // animation. It is set EAGERLY in closeDrawer (same batch as the `open`
57+ // flip and the sidebar's router.replace) so the session being navigated
58+ // TO observes it on its very first render — onTransitionStart would land
59+ // a frame late, and doesn't fire at all for gesture-settled transitions
60+ // (the source skips it when a velocity is passed). Only this programmatic,
61+ // navigation-coupled close sets it; gesture/overlay-tap dismissals leave
62+ // it false (they keep the current session mounted). openDrawer clears it:
63+ // a close reversed back into an open before it lands is no longer closing.
64+ //
5465 // The `open` guard matters: calling close while already closed must NOT
55- // set `transitioning` — no animation would run, so no onTransitionEnd
56- // would ever clear it. Setting `transitioning` EAGERLY (same batch as
57- // the open flip) is the gate's real mechanism: the component's own
58- // onTransitionStart lands a frame later AND doesn't fire at all for
59- // gesture-settled transitions (the source skips it when a velocity is
60- // passed), while the session screen must see the flag in the same batch
61- // as the route change that follows a sidebar tap.
66+ // set `closing` — no animation would run, so no onTransitionEnd would
67+ // ever clear it.
68+ //
6269 // Haptics fire at the COMMIT point — the moment `open` actually flips
6370 // (tap here, gesture release in onOpen/onClose below) — not at the
6471 // drawer's transition events: onTransitionEnd waits for the spring's
65- // mathematical rest (an overdamped sub-pixel tail well past the
66- // perceived landing → buzz felt late), and both transition events also
67- // fire for the mount-time prop-sync toggle (→ spurious buzz on every
68- // reload). Gating on the `open` flip is immediate and skips all echoes.
72+ // mathematical rest (an overdamped sub-pixel tail well past the perceived
73+ // landing → buzz felt late), and it also fires for the mount-time
74+ // prop-sync toggle (→ spurious buzz on every reload). Gating on the `open`
75+ // flip is immediate and skips all echoes.
6976 const openDrawer = useCallback ( ( ) => {
7077 if ( open ) return ;
7178 haptics . drawerToggle ( ) ;
72- setTransitioning ( true ) ;
79+ setClosing ( false ) ;
7380 setOpen ( true ) ;
7481 } , [ open ] ) ;
7582
7683 const closeDrawer = useCallback ( ( ) => {
7784 if ( ! open ) return ;
7885 haptics . drawerToggle ( ) ;
79- setTransitioning ( true ) ;
86+ setClosing ( true ) ;
8087 setOpen ( false ) ;
8188 } , [ open ] ) ;
8289
8390 const drawerUI = useMemo (
84- ( ) => ( { open, transitioning , openDrawer, closeDrawer } ) ,
85- [ open , transitioning , openDrawer , closeDrawer ] ,
91+ ( ) => ( { open, closing , openDrawer, closeDrawer } ) ,
92+ [ open , closing , openDrawer , closeDrawer ] ,
8693 ) ;
8794
8895 return (
@@ -93,29 +100,31 @@ export default function DrawerLayout() {
93100 // Gesture-driven open commits here, at release (programmatic
94101 // open came through openDrawer where `open` is already true —
95102 // and the mount/resync echoes arrive with `open` unchanged —
96- // so the state-change check dedupes all of them).
103+ // so the state-change check dedupes all of them). Clear `closing`
104+ // too: a swipe that reverses an in-flight close means we're
105+ // opening now.
97106 if ( ! open ) haptics . drawerToggle ( ) ;
107+ setClosing ( false ) ;
98108 setOpen ( true ) ;
99109 } }
100110 onClose = { ( ) => {
111+ // Gesture / overlay-tap dismissals land here. They deliberately
112+ // do NOT touch `closing` — the current session stays mounted, so
113+ // gating it would only flicker ChatPanel back to loading. The
114+ // navigation-coupled close already set `closing` in closeDrawer.
101115 if ( open ) haptics . drawerToggle ( ) ;
102116 setOpen ( false ) ;
103117 } }
104- onTransitionStart = { ( ) => setTransitioning ( true ) }
105118 onTransitionEnd = { ( closing ) => {
106- // Direction match — only the end event whose direction agrees
107- // with the current target state may clear `transitioning`. A
108- // spring that completes (finished=true) right as the OPPOSITE
109- // transition is requested delivers its end callback via runOnJS
110- // AFTER the new eager setTransitioning(true) — captured on
111- // device 2026-06-12: tapping a session row in the same frame the
112- // open animation landed let the stale "open ended" event clear
113- // the close transition's flag; the gate then released ChatPanel
114- // mid-close and the mount choke froze the drawer half-open. A
115- // stale end is always opposite-direction (that's what makes it
116- // stale), so the check closes the race exactly.
117- if ( closing !== ! open ) return ;
118- setTransitioning ( false ) ;
119+ // Only a completed CLOSE clears the gate. Open-end events carry
120+ // closing=false and are ignored — including a stale one delivered
121+ // via runOnJS AFTER a new close was already requested (captured on
122+ // device 2026-06-12: a row tapped in the same frame the open
123+ // landed let the "open ended" callback clear the close's flag,
124+ // releasing ChatPanel mid-close and freezing the drawer
125+ // half-open). Acting only on closing=true closes that race with no
126+ // direction-match against `open`.
127+ if ( closing ) setClosing ( false ) ;
119128 } }
120129 drawerType = "back"
121130 overlayStyle = { { backgroundColor : "transparent" } }
0 commit comments