Skip to content

Commit 76d5ec7

Browse files
fix(mobile): a pairing link fills the open Add environment form instead of stacking another (#26052)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d1c4b52 commit 76d5ec7

3 files changed

Lines changed: 89 additions & 3 deletions

File tree

‎apps/mobile/src/App.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal";
5252
import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking";
5353
import { hasCloudPublicConfig } from "./features/cloud/publicConfig";
5454
import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state";
55-
import { startDevAutoConnect } from "./features/connection/devAutoConnect";
55+
import { pairingFormLinkAction, startDevAutoConnect } from "./features/connection/devAutoConnect";
5656
import {
5757
resolvePendingNewTaskPromptLink,
5858
takeNewTaskPromptLink,
@@ -129,6 +129,10 @@ const appLinking = {
129129
): ReturnType<typeof getActionFromState> =>
130130
// Our router handles the selection; React Navigation's type only lists its own actions.
131131
(workspaceDetailLinkAction(state) as ReturnType<typeof getActionFromState>) ??
132+
pairingFormLinkAction(
133+
state,
134+
rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined,
135+
) ??
132136
getActionFromState(state, options),
133137
getStateFromPath: getAppStateFromPath,
134138
};

‎apps/mobile/src/features/connection/devAutoConnect.test.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
import * as NodeModule from "node:module";
12
import type { NavigationState, PartialState } from "@react-navigation/native";
23
import { expect, it, vi } from "vite-plus/test";
34

5+
const requireFromNative = NodeModule.createRequire(
6+
NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
7+
);
8+
const requireFromCore = NodeModule.createRequire(
9+
requireFromNative.resolve("@react-navigation/core/package.json"),
10+
);
11+
const { getActionFromState, getStateFromPath } = requireFromNative("@react-navigation/core") as {
12+
readonly getActionFromState: typeof import("@react-navigation/native").getActionFromState;
13+
readonly getStateFromPath: typeof import("@react-navigation/native").getStateFromPath;
14+
};
15+
const { StackRouter } = requireFromCore("@react-navigation/routers") as {
16+
readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
17+
};
18+
419
const harness = vi.hoisted(() => ({
520
pair: vi.fn(),
621
goBack: vi.fn(),
@@ -20,7 +35,12 @@ const harness = vi.hoisted(() => ({
2035
deferred: null as Array<() => void> | null,
2136
}));
2237

23-
vi.mock("@react-navigation/native", () => ({ StackActions: { replace: vi.fn() } }));
38+
vi.mock("@react-navigation/native", () => ({
39+
StackActions: { replace: vi.fn() },
40+
CommonActions: {
41+
replaceParams: (params: object) => ({ type: "REPLACE_PARAMS", payload: { params } }),
42+
},
43+
}));
2444
vi.mock("../../state/use-remote-environment-registry", () => ({
2545
pairEnvironment: harness.pair,
2646
}));
@@ -47,6 +67,7 @@ import { appAtomRegistry } from "../../state/atom-registry";
4767
import {
4868
autoConnectReport,
4969
devAutoConnectAttemptAtom,
70+
pairingFormLinkAction,
5071
startDevAutoConnect,
5172
} from "./devAutoConnect";
5273

@@ -191,3 +212,45 @@ it("a success that waited for navigation leaves a newer link's form open", async
191212
expect(harness.goBack).not.toHaveBeenCalled();
192213
expect(harness.dispatch).not.toHaveBeenCalled();
193214
});
215+
216+
it("a pairing link fills the pairing form already on screen instead of stacking a second one", () => {
217+
const linking = {
218+
initialRouteName: "Home",
219+
screens: {
220+
Home: "",
221+
Settings: "settings",
222+
SettingsEnvironmentNew: "settings/environment-new",
223+
ConnectionsNew: "connections/new",
224+
},
225+
};
226+
const router = StackRouter({});
227+
const options = {
228+
routeNames: Object.keys(linking.screens),
229+
routeParamList: {},
230+
routeGetIdList: {},
231+
};
232+
// Settings > Environments > Add environment, as the user opened it.
233+
const open = router.getRehydratedState(
234+
{
235+
index: 2,
236+
routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsEnvironmentNew" }],
237+
},
238+
options,
239+
);
240+
const link = getStateFromPath(
241+
`connections/new?pairingUrl=${encodeURIComponent(PAIRING_URL)}`,
242+
linking,
243+
)!;
244+
const action = pairingFormLinkAction(link, open) ?? getActionFromState(link, linking)!;
245+
const next = router.getStateForAction(open, { target: open.key, ...action }, options);
246+
247+
expect(next?.routes.map((route) => route.name)).toEqual([
248+
"Home",
249+
"Settings",
250+
"SettingsEnvironmentNew",
251+
]);
252+
expect(next?.routes.at(-1)).toMatchObject({
253+
key: open.routes[2]!.key,
254+
params: { pairingUrl: PAIRING_URL },
255+
});
256+
});

‎apps/mobile/src/features/connection/devAutoConnect.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { NavigationState, PartialState } from "@react-navigation/native";
2-
import { StackActions } from "@react-navigation/native";
2+
import { CommonActions, StackActions } from "@react-navigation/native";
33
import type { EnvironmentId } from "@t3tools/contracts";
44
import { Atom } from "effect/reactivity";
55

@@ -97,3 +97,22 @@ export function startDevAutoConnect(
9797
}
9898
return state;
9999
}
100+
101+
/**
102+
* A pairing link that lands on an open pairing form fills that form. React Navigation would push
103+
* `ConnectionsNew` over the Settings page's `SettingsEnvironmentNew`, stacking two copies of the
104+
* same form. The form keys on the link's pairing URL, so new params remount it with the prefill.
105+
*/
106+
export function pairingFormLinkAction(
107+
link: PartialState<NavigationState>,
108+
current: NavigationState | undefined,
109+
) {
110+
const linked = findLinkedRoute(link, PAIRING_FORM_ROUTES);
111+
const open = current?.routes[current.index];
112+
if (!linked || !current || !open || !PAIRING_FORM_ROUTES.includes(open.name)) return undefined;
113+
return {
114+
...CommonActions.replaceParams(linked.params ?? {}),
115+
source: open.key,
116+
target: current.key,
117+
};
118+
}

0 commit comments

Comments
 (0)