Skip to content

Commit 37c30ef

Browse files
yyq1025claude
andcommitted
drawer: track closing instead of transitioning for the mount gate
The session-screen mount gate only ever cares about the close animation (a heavy ChatPanel mount competes with it). `transitioning` tracked both directions, which forced a sticky `settledFor` latch + per-session keying just so an OPEN transition wouldn't revert an already-settled ChatPanel to loading. Scope the flag to navigation-coupled closes (`closing`, set eagerly in closeDrawer only) and the gate collapses to a derived `!closing`: no useState, no useEffect, no string keying. Opens no longer touch the flag, so the latch is unnecessary, and the onTransitionEnd direction-match (`closing !== !open`) reduces to "ignore open-end events" — the captured 2026-06-12 stale-end race is still closed. Gesture/overlay-tap dismissals deliberately don't set `closing` (they keep the current session mounted), and onTransitionStart is dropped (it was redundant for the gate and would have gated tap-to-dismiss). openDrawer/onOpen clear `closing` to handle a close reversed mid-flight back into an open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 29533b4 commit 37c30ef

4 files changed

Lines changed: 77 additions & 69 deletions

File tree

packages/app/src/app/(main)/(drawer)/(stack)/session/[cliSessionId].tsx

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { eq, useLiveQuery } from "@tanstack/react-db";
22
import { Stack, useLocalSearchParams } from "expo-router";
3-
import { useEffect, useMemo, useState } from "react";
3+
import { useEffect, useMemo } from "react";
44
import { Text, View } from "react-native";
55
import { SessionBridgeToolbar } from "@/components/session-bridge-toolbar";
66
import { ChatPanel } from "@/components/transcript/chat-panel";
@@ -62,22 +62,18 @@ export default function SessionDetailScreen() {
6262
// Drawer-settle gate. Mounting ChatPanel is heavy on the MAIN thread
6363
// (native view inflation + enriched's dispatch_sync measure), which the
6464
// drawer-close animation (Reanimated, UI thread) directly competes with —
65-
// JS fps can look fine while the close visibly stutters. While the drawer
66-
// is animating (sidebar tap → closeDrawer flips `transitioning` in the
67-
// same batch as the route swap), render the cheap TranscriptLoading;
68-
// mount ChatPanel once the animation ends. Keyed by cliSessionId so
69-
// switching sessions through the drawer re-gates each time; entries with
70-
// no drawer animation (new-session replace, deep links) settle on the
71-
// first effect pass. Plain context state — no timers, no params, no
72-
// navigation events (see drawer-ui.tsx).
73-
const { transitioning, openDrawer } = useDrawerUI();
74-
const [settledFor, setSettledFor] = useState<string | null>(
75-
transitioning ? null : cliSessionId,
76-
);
77-
const drawerSettled = settledFor === cliSessionId;
78-
useEffect(() => {
79-
if (!transitioning) setSettledFor(cliSessionId);
80-
}, [transitioning, cliSessionId]);
65+
// JS fps can look fine while the close visibly stutters. A sidebar tap
66+
// flips `closing` (in closeDrawer) in the same batch as the route swap, so
67+
// the session being navigated TO mounts with `closing === true` and shows
68+
// the cheap TranscriptLoading; ChatPanel mounts once the close lands
69+
// (onTransitionEnd clears `closing`). Every other entry path — deep link,
70+
// new-session replace, the drawer merely opening over an already-settled
71+
// session — has `closing === false`. Because `closing` is scoped to
72+
// navigation-coupled closes (see drawer-ui.tsx) it never flips true under a
73+
// mounted session, so the gate is just a derived boolean: no state, no
74+
// effect, no per-session keying.
75+
const { closing, openDrawer } = useDrawerUI();
76+
const drawerSettled = !closing;
8177

8278
// Log turn-failure errors. UI is intentionally absent for V0 —
8379
// surfacing assistant errors well needs design work we haven't done

packages/app/src/app/(main)/(drawer)/_layout.tsx

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
4950
export 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" }}

packages/app/src/components/session-list-sidebar.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ export function SessionListSidebar() {
134134
// the project the user just opened. Mutation is fire-and-forget;
135135
// the navigation below shouldn't wait on SecureStore I/O.
136136
setLastUsedCwd.mutate(session.cwd);
137-
// closeDrawer flips `transitioning` in the same batch, so the route
138-
// swap below mounts only the cheap TranscriptLoading — the session
139-
// screen's drawer-settle gate holds the heavy ChatPanel mount until
140-
// the close animation finishes. No frame-deferral needed.
137+
// closeDrawer flips `closing` in the same batch, so the route swap
138+
// below mounts only the cheap TranscriptLoading — the session screen's
139+
// drawer-settle gate holds the heavy ChatPanel mount until the close
140+
// animation finishes. No frame-deferral needed.
141141
closeDrawer();
142142
router.replace({
143143
pathname: "/session/[cliSessionId]",

packages/app/src/lib/drawer-ui.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@ import { createContext, useContext } from "react";
33
/**
44
* UI state for the session-list drawer (bare react-native-drawer-layout —
55
* deliberately NOT a drawer navigator: the drawer hosts no routes, and
6-
* owning `open`/`transitioning` as plain React state is what lets the
7-
* session screen gate its heavy ChatPanel mount on the close animation
8-
* with zero timers, params, or navigation-event plumbing. Provided by
9-
* the (drawer) group layout.
6+
* owning `open`/`closing` as plain React state is what lets the session
7+
* screen gate its heavy ChatPanel mount on the close animation with zero
8+
* timers, params, or navigation-event plumbing. Provided by the (drawer)
9+
* group layout.
1010
*
11-
* `transitioning` is true from the moment a programmatic open/close is
12-
* requested (set eagerly, same batch as the `open` flip — the component's
13-
* own onTransitionStart would arrive a frame late) until the drawer's
14-
* onTransitionEnd — direction-matched: an end event whose `closing` arg
15-
* disagrees with the current target state is a stale callback from the
16-
* superseded opposite transition and is ignored (see the layout's
17-
* onTransitionEnd comment for the captured race). Gesture-driven
18-
* transitions are covered by onTransitionStart/onTransitionEnd directly.
11+
* `closing` is true only while a programmatic, navigation-coupled close is
12+
* animating — i.e. the close `closeDrawer` fires from a sidebar tap, paired
13+
* in the same batch with the `router.replace` to the new session. It is set
14+
* eagerly in `closeDrawer` (so the screen being navigated TO sees it on its
15+
* first render) and cleared on the matching onTransitionEnd, or earlier if
16+
* the close reverses back into an open (openDrawer/onOpen). It is
17+
* deliberately NOT set for gesture / overlay-tap dismissals or for open
18+
* transitions: those keep the current session mounted, so a plain
19+
* `!closing` is all the gate needs (see the session screen). Tracking only
20+
* the close direction also dissolves the old stale-end race — an open-end
21+
* callback carries closing=false and is simply ignored.
1922
*/
2023
export interface DrawerUI {
2124
open: boolean;
22-
/** A drawer open/close animation is in flight. */
23-
transitioning: boolean;
25+
/** A navigation-coupled drawer-close animation is in flight. */
26+
closing: boolean;
2427
openDrawer: () => void;
2528
closeDrawer: () => void;
2629
}

0 commit comments

Comments
 (0)