authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 09:17:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 09:17:25-07:00
loga6ec88f7a716fc421bd22c2484881c44110f9375
tree61c26ff35bddd66e24f0a82ef44fab5e57bdc848
parenta65b43892f14bd6c1280296a37a11bd5234be00d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep native screens ordered during stack pops (#17231)


3 files changed, 39 insertions(+), 11 deletions(-)

apps/mobile/src/native/createV5StackNavigator.ios.tsx+8-1
......@@ -181,7 +181,14 @@ export function V5CardStackView(props: V5StackViewProps) {
181181 >
182182 <NavigationContext value={descriptor.navigation}>
183183 <NavigationRouteContext value={descriptor.route}>
184 <V5StackHeader options={descriptor.options} canGoBack={index > 0} />
184 <V5StackHeader
185 options={descriptor.options}
186 canGoBack={
187 attached
188 ? props.state.routes.findIndex((current) => current.key === route.key) > 0
189 : index > 0
190 }
191 />
185192 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>
186193 </NavigationRouteContext>
187194 </NavigationContext>
apps/mobile/src/native/workspace-stack-projection.test.ts+23-3
......@@ -143,21 +143,41 @@ describe("v5 stack handoff", () => {
143143 });
144144 it("still retains an unfinished JS pop beside a completed native pop", () => {
145145 expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([
146 thread,
147146 home,
147 thread,
148148 ]);
149149 });
150150 it("retains removed native screens through a pop followed immediately by a push", () => {
151151 const popped = reconcileStackScreens([home, thread, files], [home, thread]);
152152 const next = { ...files, key: "new-files" };
153153 const screens = reconcileStackScreens(popped, [home, thread, next]);
154 expect(screens.map((route) => route.key)).toEqual(["files", "home", "thread", "new-files"]);
154 expect(screens.map((route) => route.key)).toEqual(["home", "thread", "files", "new-files"]);
155155 expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([
156156 home,
157157 thread,
158158 next,
159159 ]);
160 expect(screens[0]).toBe(files);
160 expect(screens[2]).toBe(files);
161 });
162 it("keeps the root and outgoing settings page in place until the JS pop finishes", () => {
163 const environments = { ...files, key: "environments", name: "SettingsEnvironments" };
164 const popped = reconcileStackScreens([settings, environments], [settings]);
165 expect(popped).toEqual([settings, environments]);
166 expect(reconcileStackScreens(popped, [settings])).toEqual(popped);
167 const next = { ...environments, key: "next-environments" };
168 expect(reconcileStackScreens(popped, [settings, next])).toEqual([settings, environments, next]);
169 expect(reconcileStackScreens(popped, [settings], new Set([environments.key]))).toEqual([
170 settings,
171 ]);
172 });
173 it("retains a multi-page pop in order without changing the active router order", () => {
174 const replacement = { ...thread, key: "replacement" };
175 const screens = reconcileStackScreens([home, thread, files], [home, replacement]);
176 expect(screens).toEqual([home, thread, files, replacement]);
177 expect(screens.filter((route) => route === home || route === replacement)).toEqual([
178 home,
179 replacement,
180 ]);
161181 });
162182 it("updates params without retaining duplicate copies of the same screen", () => {
163183 const updated = { ...thread, params: { threadId: "another-thread" } };
apps/mobile/src/native/workspace-stack-projection.ts+8-7
......@@ -57,17 +57,18 @@ export function partitionStackPresentations<T>(
5757 return groups;
5858}
5959
60/** Preserve UIKit's outgoing screens, before newly pushed screens, until dismissal. */
60/** Keep outgoing screens in place until dismissal; Fabric moves enqueue native pop/push operations. */
6161export function reconcileStackScreens<T extends { readonly key: string }>(
6262 previous: readonly T[],
6363 current: readonly T[],
6464 completedNativeDismissals: ReadonlySet<string> = new Set(),
6565): T[] {
6666 const active = new Set(current.map((route) => route.key));
67 return [
68 ...previous.filter(
69 (route) => !active.has(route.key) && !completedNativeDismissals.has(route.key),
70 ),
71 ...current,
72 ];
67 const screens = [...current];
68 previous.forEach((route, index) => {
69 if (!active.has(route.key) && !completedNativeDismissals.has(route.key)) {
70 screens.splice(Math.min(index, screens.length), 0, route);
71 }
72 });
73 return screens;
7374}