# Files changed · PR \#22526 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22526/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): folded Back clears the detail route before the next selection](https://git.cafe/pingdotgg/t3code/pull/22526.md)

Version: 7

Head commit: f9a3b5c2fbbf856382a26f81909690eb49c9e284

Base commit: aaa7380015abcf64411a887d28a596b52367001c

Comparison base: aaa7380015abcf64411a887d28a596b52367001c

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

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

Status: modified; +42; −12

```diff
@@ -44,9 +44,11 @@
 import { V5StackHeader } from "../../native/V5StackHeader.ios";
 import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
 import {
+  collapsedSplitTransition,
   nativeWorkspacePopAction,
   projectWorkspaceStack,
   partitionStackPresentations,
+  type CollapsedSplitColumn,
 } from "../../native/workspace-stack-projection";
 import {
   NativePrimaryColumnContext,
@@ -65,6 +67,7 @@
   readonly selectedPullRequestKey?: string | null;
   readonly sidebarFilter?: NativeStackHeaderItemMenu;
   readonly onDismiss: (routeKey: string) => void;
+  readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void;
   readonly navigation: ViewProps["navigation"];
 }) {
   const { descriptor } = props;
@@ -84,16 +87,19 @@
       screenKey={key}
       onDismiss={(event) => {
         if (event.nativeEvent.isNativeDismiss) props.onDismiss(key);
+      }}
+      onWillAppear={() => {
+        props.onAppearance("willAppear", key);
+        props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } });
       }}
+      onDidAppear={() => {
+        props.onAppearance("didAppear", key);
+        props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } });
+      }}
+      onWillDisappear={() => {
+        props.onAppearance("willDisappear", key);
+        props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } });
-      onWillAppear={() =>
-        props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } })
-      }
+      }}
-      onDidAppear={() =>
-        props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } })
-      }
-      onWillDisappear={() =>
-        props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } })
-      }
       onDidDisappear={() =>
         props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } })
       }
@@ -130,7 +136,8 @@
   const hostRef = useRef<SplitHostCommands>(null);
   const activeDetailKey = props.detail.at(-1)?.key;
   const compactColumn = activeDetailKey ? "secondary" : "primary";
+  const collapsed = useRef(!layout.usesSplitView);
+  const collapsedColumn = useRef<CollapsedSplitColumn>({ visible: compactColumn, leaving: null });
-  const shownCompactColumn = useRef(compactColumn);
   const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
   const pullRequestParams = props.detail.findLast(
     (route) =>
@@ -169,10 +176,24 @@
     },
     [props.navigation],
   );
+  const followCollapsedSplit = (
+    type: "willAppear" | "didAppear" | "willDisappear",
+    key: string,
+  ) => {
+    if (!collapsed.current || layout.usesSplitView) return;
+    const transition = collapsedSplitTransition(
+      collapsedColumn.current,
+      { type, key },
+      props.navigation.getState(),
+      props.primary.key,
+    );
+    collapsedColumn.current = transition.column;
+    if (transition.pop) props.navigation.dispatch(transition.pop);
+  };
 
   useEffect(() => {
-    if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return;
-    shownCompactColumn.current = compactColumn;
+    if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return;
+    collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn };
     hostRef.current?.show(compactColumn);
   }, [compactColumn, layout.usesSplitView]);
 
@@ -185,6 +206,13 @@
       testID="adaptive-workspace-layout"
       preferredSplitBehavior="tile"
       topColumnForCollapsing={compactColumn}
+      onCollapse={() => {
+        collapsed.current = true;
+        collapsedColumn.current = { visible: compactColumn, leaving: null };
+      }}
+      onExpand={() => {
+        collapsed.current = false;
+      }}
       preferredDisplayMode={
         panes.primarySidebarVisible || (!layout.usesSplitView && !activeDetailKey)
           ? "oneBesideSecondary"
@@ -211,6 +239,7 @@
             selectedThreadKey={selectedThreadKey}
             selectedPullRequestKey={selectedPullRequestKey}
             onDismiss={handleNativeDismiss}
+            onAppearance={followCollapsedSplit}
             navigation={props.navigation}
           />
         </Split.Stack>
@@ -252,6 +281,7 @@
                 key={route.key}
                 descriptor={descriptor}
                 onDismiss={handleNativeDismiss}
+                onAppearance={followCollapsedSplit}
                 navigation={props.navigation}
                 sidebarFilter={sidebarFilter}
               />
```

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

Status: modified; +107; −0

```diff
@@ -3,12 +3,14 @@
 import { describe, expect, it } from "vite-plus/test";
 
 import {
+  collapsedSplitTransition,
   nativeStackPopAction,
   nativeWorkspacePopAction,
   nativeWorkspacePopCount,
   projectWorkspaceStack,
   partitionStackPresentations,
   reconcileStackScreens,
+  type CollapsedSplitColumn,
 } from "./workspace-stack-projection";
 
 const requireNavigation = NodeModule.createRequire(
@@ -185,6 +187,111 @@
   });
 });
 
+describe("collapsed split Back", () => {
+  const routerOptions = {
+    routeNames: history([home]).routeNames,
+    routeParamList: {},
+    routeGetIdList: {},
+  };
+  type Appearance = "willAppear" | "didAppear" | "willDisappear";
+  // UIKit's appearance callbacks for one collapsed transition, against the router at each step.
+  function replay(
+    steps: ReadonlyArray<readonly [Appearance, string, StackNavigationState<ParamListBase>]>,
+  ) {
+    let column: CollapsedSplitColumn = { visible: "secondary", leaving: null };
+    let pop: ReturnType<typeof nativeStackPopAction> = null;
+    for (const [type, key, state] of steps) {
+      const transition = collapsedSplitTransition(column, { type, key }, state, home.key);
+      column = transition.column;
+      pop ??= transition.pop;
+    }
+    return { visible: column.visible, pop };
+  }
+
+  it("returns the router to the list so the next selection opens a new detail", () => {
+    const state = history([home, thread]);
+    const back = replay([
+      ["willDisappear", thread.key, state],
+      ["willAppear", home.key, state],
+      ["didAppear", home.key, state],
+    ]);
+    expect(back.visible).toBe("primary");
+    const router = StackRouter({});
+    const listed = router.getStateForAction(state, back.pop!, routerOptions);
+    expect(listed?.routes).toEqual([home]);
+    if (!listed || listed.stale !== false) throw new Error("Expected a rehydrated router history");
+    const select = {
+      type: "NAVIGATE",
+      payload: { name: "Thread", params: { threadId: "next" } },
+    } as const;
+    expect(router.getStateForAction(listed, select, routerOptions)?.routes[1]?.key).not.toBe(
+      thread.key,
+    );
+    // Left on the hidden detail, the selection would only rename it and never show the column.
+    expect(router.getStateForAction(state, select, routerOptions)?.routes[1]?.key).toBe(thread.key);
+  });
+
+  it("clears every detail while keeping a presentation opened during the Back animation", () => {
+    const leaving = history([home, thread, files]);
+    const presented = history([home, thread, files, settings]);
+    const { pop } = replay([
+      ["willDisappear", files.key, leaving],
+      ["didAppear", home.key, presented],
+    ]);
+    expect(StackRouter({}).getStateForAction(presented, pop!, routerOptions)?.routes).toEqual([
+      home,
+      settings,
+    ]);
+  });
+
+  it("keeps the detail when it appears again before the list finishes appearing", () => {
+    const state = history([home, thread]);
+    const cancelledSwipe = replay([
+      ["willDisappear", thread.key, state],
+      ["willAppear", home.key, state],
+      ["willAppear", thread.key, state],
+      ["didAppear", thread.key, state],
+    ]);
+    expect(cancelledSwipe).toEqual({ visible: "secondary", pop: null });
+    expect(
+      replay([
+        ["willDisappear", thread.key, state],
+        ["willAppear", thread.key, state],
+        ["didAppear", home.key, state],
+      ]).pop,
+    ).toBeNull();
+  });
+
+  it("does not mistake JS navigation or a cold link for native Back", () => {
+    const pushed = history([home, thread, files]);
+    expect(
+      replay([
+        ["willDisappear", thread.key, pushed],
+        ["didAppear", files.key, pushed],
+      ]),
+    ).toEqual({ visible: "secondary", pop: null });
+    const presented = history([home, thread, settings]);
+    expect(
+      replay([
+        ["willDisappear", thread.key, presented],
+        ["didAppear", home.key, presented],
+      ]).pop,
+    ).toBeNull();
+    const popped = history([home]);
+    expect(
+      replay([
+        ["willDisappear", thread.key, popped],
+        ["didAppear", home.key, popped],
+      ]).pop,
+    ).toBeNull();
+    // A linked thread whose list appears first still needs its detail column shown.
+    expect(replay([["didAppear", home.key, history([home, thread])]])).toEqual({
+      visible: "primary",
+      pop: null,
+    });
+  });
+});
+
 describe("v5 stack handoff", () => {
   it("releases a completed native pop when the router acknowledges it", () => {
     const completed = new Set([files.key]);
```

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

Status: modified; +47; −0

```diff
@@ -68,8 +68,55 @@
     source: dismissedKey,
     target: state.key,
   };
+}
+
+/** The column UIKit shows while a split is collapsed, and the active detail it began removing. */
+export interface CollapsedSplitColumn {
+  readonly visible: "primary" | "secondary";
+  readonly leaving: string | null;
 }
 
+/**
+ * A collapsed split nests the detail column's navigation controller in the list column's. Back
+ * from the detail root pops that controller whole, so no screen leaves its own stack or reports a
+ * dismissal: the list appearing after the active detail began leaving is the only sign of it.
+ */
+export function collapsedSplitTransition(
+  column: CollapsedSplitColumn,
+  event: { readonly type: "willAppear" | "didAppear" | "willDisappear"; readonly key: string },
+  state: Pick<NavigationState, "key" | "index" | "routes">,
+  primaryKey: string,
+): {
+  readonly column: CollapsedSplitColumn;
+  readonly pop: ReturnType<typeof nativeStackPopAction>;
+} {
+  switch (event.type) {
+    case "willDisappear":
+      // JS pushes, pops, and presentations have already moved the router past this screen.
+      return {
+        column:
+          state.index > 0 && state.routes[state.index]?.key === event.key
+            ? { ...column, leaving: event.key }
+            : column,
+        pop: null,
+      };
+    case "willAppear":
+      // A cancelled swipe, or an expansion that keeps the detail on screen.
+      return {
+        column: column.leaving === event.key ? { ...column, leaving: null } : column,
+        pop: null,
+      };
+    case "didAppear":
+      return {
+        column: { visible: event.key === primaryKey ? "primary" : "secondary", leaving: null },
+        pop:
+          event.key === primaryKey && column.leaving
+            ? nativeStackPopAction(state, column.leaving, primaryKey)
+            : null,
+      };
+  }
+}
+
 export function partitionStackPresentations<T>(
   routes: readonly T[],
   isModal: (route: T) => boolean,
```
