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) {...@@ -181,7 +181,14 @@ export function V5CardStackView(props: V5StackViewProps) {
181 >181 >
182 <NavigationContext value={descriptor.navigation}>182 <NavigationContext value={descriptor.navigation}>
183 <NavigationRouteContext value={descriptor.route}>183 <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 />
185 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>192 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>
186 </NavigationRouteContext>193 </NavigationRouteContext>
187 </NavigationContext>194 </NavigationContext>
apps/mobile/src/native/workspace-stack-projection.test.ts+23-3
...@@ -143,21 +143,41 @@ describe("v5 stack handoff", () => {...@@ -143,21 +143,41 @@ describe("v5 stack handoff", () => {
143 });143 });
144 it("still retains an unfinished JS pop beside a completed native pop", () => {144 it("still retains an unfinished JS pop beside a completed native pop", () => {
145 expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([145 expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([
146 thread,
147 home,146 home,
147 thread,
148 ]);148 ]);
149 });149 });
150 it("retains removed native screens through a pop followed immediately by a push", () => {150 it("retains removed native screens through a pop followed immediately by a push", () => {
151 const popped = reconcileStackScreens([home, thread, files], [home, thread]);151 const popped = reconcileStackScreens([home, thread, files], [home, thread]);
152 const next = { ...files, key: "new-files" };152 const next = { ...files, key: "new-files" };
153 const screens = reconcileStackScreens(popped, [home, thread, next]);153 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"]);
155 expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([155 expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([
156 home,156 home,
157 thread,157 thread,
158 next,158 next,
159 ]);159 ]);
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 ]);
161 });181 });
162 it("updates params without retaining duplicate copies of the same screen", () => {182 it("updates params without retaining duplicate copies of the same screen", () => {
163 const updated = { ...thread, params: { threadId: "another-thread" } };183 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>(...@@ -57,17 +57,18 @@ export function partitionStackPresentations<T>(
57 return groups;57 return groups;
58}58}
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. */
61export function reconcileStackScreens<T extends { readonly key: string }>(61export function reconcileStackScreens<T extends { readonly key: string }>(
62 previous: readonly T[],62 previous: readonly T[],
63 current: readonly T[],63 current: readonly T[],
64 completedNativeDismissals: ReadonlySet<string> = new Set(),64 completedNativeDismissals: ReadonlySet<string> = new Set(),
65): T[] {65): T[] {
66 const active = new Set(current.map((route) => route.key));66 const active = new Set(current.map((route) => route.key));
67 return [67 const screens = [...current];
68 ...previous.filter(68 previous.forEach((route, index) => {
69 (route) => !active.has(route.key) && !completedNativeDismissals.has(route.key),69 if (!active.has(route.key) && !completedNativeDismissals.has(route.key)) {
70 ),70 screens.splice(Math.min(index, screens.length), 0, route);
71 ...current,71 }
72 ];72 });
73 return screens;
73}74}