Skip to content

Commit 15cb792

Browse files
fix(mobile): preserve navigation history during native dismissals
1 parent ba3dd34 commit 15cb792

9 files changed

Lines changed: 63 additions & 28 deletions

‎apps/mobile/src/features/files/FileTreeBrowser.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export function FileTreeBrowser(props: {
253253
// list as direct screen content so automatic inset adjustment can find it.
254254
return (
255255
<LegendList
256+
contentInsetStartAdjustment={headerInset}
256257
alwaysBounceVertical
257258
className="flex-1"
258259
data={visibleNodes}

‎apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
createNavigatorFactory,
33
NavigationContext,
44
NavigationRouteContext,
5-
StackActions,
65
StackRouter,
76
useNavigationBuilder,
87
type NavigatorTypeBagBase,
@@ -37,7 +36,7 @@ import {
3736
import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns";
3837
import { V5StackHeader } from "../../native/V5StackHeader.ios";
3938
import {
40-
nativeWorkspacePopCount,
39+
nativeWorkspacePopAction,
4140
projectWorkspaceStack,
4241
partitionStackPresentations,
4342
} from "../../native/workspace-stack-projection";
@@ -123,9 +122,8 @@ function WorkspaceColumns(
123122
const handleNativeDismiss = useCallback(
124123
(key: string) => {
125124
const state = props.navigation.getState();
126-
const count = nativeWorkspacePopCount(state, key);
127-
if (count)
128-
props.navigation.dispatch({ ...StackActions.pop(count), source: key, target: state.key });
125+
const action = nativeWorkspacePopAction(state, key);
126+
if (action) props.navigation.dispatch(action);
129127
},
130128
[props.navigation],
131129
);

‎apps/mobile/src/features/settings/SettingsRouteScreen.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ export function SettingsRouteScreen() {
2929

3030
return (
3131
<>
32+
{Platform.OS === "ios" ? (
33+
<NativeStackScreenOptions options={{ headerBackVisible: !layout.usesSplitView }} />
34+
) : null}
3235
{Platform.OS === "ios" && layout.usesSplitView ? (
3336
<>
34-
<NativeStackScreenOptions options={{ headerBackVisible: false }} />
3537
<NativeHeaderToolbar placement="left">
3638
<NativeHeaderToolbar.Button
3739
accessibilityLabel="Go back"

‎apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1206,7 +1206,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12061206
: terminalTheme.background,
12071207
// The v5 column extends behind UIKit chrome. Its measured top
12081208
// reservation is zero when UIKit has already inset the body or moved the bar.
1209-
paddingTop: Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? 0) : 0,
1209+
paddingTop:
1210+
Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0,
12101211
paddingBottom:
12111212
Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
12121213
}}

‎apps/mobile/src/native/V5StackHeader.ios.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte
111111
identifier: item.identifier,
112112
title: item.label || item.accessibilityLabel || undefined,
113113
icon: convertIcon(item.icon),
114-
menu: convertMenu(item.menu, `${id}:menu`),
114+
menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),
115115
hidesSharedBackground: item.hidesSharedBackground,
116116
};
117117
case "button":

‎apps/mobile/src/native/createV5StackNavigator.ios.tsx‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
createNavigatorFactory,
33
NavigationContext,
44
NavigationRouteContext,
5-
StackActions,
65
StackRouter,
76
useNavigationBuilder,
87
usePreventRemoveContext,
@@ -27,6 +26,7 @@ import { FormSheet, Stack } from "react-native-screens";
2726
import { V5StackHeader } from "./V5StackHeader.ios";
2827
import { NativeColumnContent } from "./NativeColumnContent.ios";
2928
import {
29+
nativeWorkspacePopAction,
3030
nativeWorkspacePopCount,
3131
partitionStackPresentations,
3232
reconcileStackScreens,
@@ -108,9 +108,8 @@ export function V5CardStackView(props: V5StackViewProps) {
108108
descriptors,
109109
};
110110
});
111-
const count = nativeWorkspacePopCount(state, key);
112-
if (count)
113-
props.navigation.dispatch({ ...StackActions.pop(count), source: key, target: state.key });
111+
const action = nativeWorkspacePopAction(state, key);
112+
if (action) props.navigation.dispatch(action);
114113
},
115114
[props.navigation, setScreens],
116115
);
@@ -296,7 +295,7 @@ export function V5SheetStackView(props: V5StackViewProps) {
296295
const descriptor = props.descriptors[first.key] ?? retained.descriptors[first.key];
297296
if (!descriptor) return null;
298297
const options = descriptor.options;
299-
const attached = props.state.routes.some((route) => route.key === first.key);
298+
const attached = nativeWorkspacePopCount(props.state, first.key) > 0;
300299
return (
301300
<FormSheet
302301
key={first.key}
@@ -318,25 +317,15 @@ export function V5SheetStackView(props: V5StackViewProps) {
318317
const guarded = group.findLast((route) => preventedRoutes[route.key]?.preventRemove);
319318
if (guarded) {
320319
const state = props.navigation.getState();
321-
const count = nativeWorkspacePopCount(state, first.key);
322-
if (count)
323-
props.navigation.dispatch({
324-
...StackActions.pop(count),
325-
source: first.key,
326-
target: state.key,
327-
});
320+
const action = nativeWorkspacePopAction(state, first.key);
321+
if (action) props.navigation.dispatch(action);
328322
}
329323
}}
330324
onDismiss={() => removeSheet(first.key)}
331325
onNativeDismiss={() => {
332326
const state = props.navigation.getState();
333-
const count = nativeWorkspacePopCount(state, first.key);
334-
if (count)
335-
props.navigation.dispatch({
336-
...StackActions.pop(count),
337-
source: first.key,
338-
target: state.key,
339-
});
327+
const action = nativeWorkspacePopAction(state, first.key);
328+
if (action) props.navigation.dispatch(action);
340329
removeSheet(first.key);
341330
}}
342331
>

‎apps/mobile/src/native/workspace-stack-projection.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
import * as NodeModule from "node:module";
12
import type { ParamListBase, StackNavigationState } from "@react-navigation/native";
23
import { describe, expect, it } from "vite-plus/test";
34

45
import {
6+
nativeWorkspacePopAction,
57
nativeWorkspacePopCount,
68
projectWorkspaceStack,
79
partitionStackPresentations,
810
reconcileStackScreens,
911
} from "./workspace-stack-projection";
1012

13+
// Load the same router as native navigation without importing React Native into Node.
14+
const requireNavigation = NodeModule.createRequire(
15+
NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
16+
);
17+
const { StackRouter } = requireNavigation("@react-navigation/routers") as {
18+
StackRouter: typeof import("@react-navigation/native").StackRouter;
19+
};
20+
1121
const home = { key: "home", name: "Home" };
1222
const thread = { key: "thread", name: "Thread", params: { threadId: "draft-thread" } };
1323
const files = { key: "files", name: "ThreadFiles", params: thread.params };
@@ -78,6 +88,24 @@ describe("workspace router projection", () => {
7888
});
7989

8090
describe("native workspace dismissal", () => {
91+
it("dismisses a sheet and its pushed pages without removing the underlying draft", () => {
92+
const state = history([home, thread, settings, legal]);
93+
const action = nativeWorkspacePopAction(state, settings.key)!;
94+
const next = StackRouter({}).getStateForAction(state, action, {
95+
routeNames: state.routeNames,
96+
routeParamList: {},
97+
routeGetIdList: {},
98+
});
99+
expect(next?.routes).toEqual([home, thread]);
100+
expect(next?.routes[1]).toBe(thread);
101+
expect(next?.index).toBe(1);
102+
});
103+
104+
it("does not dismiss retained sheets beyond the active index", () => {
105+
const state = { ...history([home, thread, settings, legal]), index: 1 };
106+
expect(nativeWorkspacePopAction(state, settings.key)).toBeNull();
107+
expect(nativeWorkspacePopAction(state, legal.key)).toBeNull();
108+
});
81109
it("pops a native dismissed file while keeping the conversation and its draft mounted", () => {
82110
expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1);
83111
});

‎apps/mobile/src/native/workspace-stack-projection.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export function nativeWorkspacePopCount(
2828
return index <= 0 || index > state.index ? 0 : state.index - index + 1;
2929
}
3030

31+
/** POP counts backwards from its source, so dismiss a group from the active descendant. */
32+
export function nativeWorkspacePopAction(
33+
state: Pick<NavigationState, "key" | "index" | "routes">,
34+
dismissedKey: string,
35+
) {
36+
const count = nativeWorkspacePopCount(state, dismissedKey);
37+
return count
38+
? {
39+
type: "POP" as const,
40+
payload: { count },
41+
source: state.routes[state.index]!.key,
42+
target: state.key,
43+
}
44+
: null;
45+
}
46+
3147
/** Group pushes with the modal that owns their native stack. */
3248
export function partitionStackPresentations<T>(
3349
routes: readonly T[],

‎docs/operations/mobile-screens-fork.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Building the mobile Screens dependency
22

33
The mobile v5 stack uses a committed tarball from the
4-
[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3/main).
4+
[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7).
55
The fork owns the integration changes; the app does not patch this package.
66
Each archive includes compiled JavaScript, declarations, native source and
77
`t3-fork.json` recording its version, source commit and upstream base.

0 commit comments

Comments
 (0)