Skip to content

Commit 0f79543

Browse files
committed
Fix T3 Connect onboarding account races and reload gaps
- Trigger mobile onboarding on account switches, not only null -> account - Drop stale onboarding requests when the signed-in account changes - Persist mobile sheet completion only for the account it opened for - Reload publish targets when bearer connections surface after first pass - Open web wizard only after an in-session sign-in, matching mobile
1 parent 53ab016 commit 0f79543

4 files changed

Lines changed: 95 additions & 18 deletions

File tree

apps/mobile/src/features/cloud/CloudAuthProvider.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
setAgentAwarenessRelayTokenProvider,
1919
unregisterAgentAwarenessDeviceForCurrentUser,
2020
} from "../agent-awareness/remoteRegistration";
21-
import { requestConnectOnboardingIfNeeded } from "./connectOnboarding";
21+
import {
22+
requestConnectOnboardingIfNeeded,
23+
syncConnectOnboardingAccount,
24+
} from "./connectOnboarding";
2225
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
2326

2427
function resetManagedRelayTokenCache() {
@@ -67,11 +70,17 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
6770
const previousObservedAccount = observedAccountRef.current;
6871
const nextAccount = isSignedIn && userId ? userId : null;
6972
observedAccountRef.current = nextAccount;
73+
syncConnectOnboardingAccount(nextAccount);
7074

71-
// A sign-in that completed during this session (a cold start observes
72-
// undefined → account and must not re-prompt) requests the T3 Connect
73-
// onboarding sheet once per account.
74-
if (previousObservedAccount === null && nextAccount !== null) {
75+
// A sign-in that completed during this session — from signed-out or by
76+
// switching accounts (a cold start observes undefined → account and must
77+
// not re-prompt) — requests the T3 Connect onboarding sheet once per
78+
// account.
79+
if (
80+
previousObservedAccount !== undefined &&
81+
previousObservedAccount !== nextAccount &&
82+
nextAccount !== null
83+
) {
7584
void (async () => {
7685
const result = await settlePromise(() => requestConnectOnboardingIfNeeded(nextAccount));
7786
reportAtomCommandResult(result, { label: "connect onboarding request" });

apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,23 @@ function ConfiguredConnectOnboardingRouteScreen() {
7878
cloudEnvironments: null,
7979
});
8080

81-
// Dismissing the sheet (swipe, Skip, or Done) counts as completion.
81+
// Dismissing the sheet (swipe, Skip, or Done) counts as completion — but
82+
// only for the account the sheet opened for. Signing out or switching
83+
// accounts mid-flow dismisses without persisting, so the new account still
84+
// gets its own onboarding.
85+
const openedForAccountRef = useRef<string | null>(null);
86+
if (openedForAccountRef.current === null && userId) {
87+
openedForAccountRef.current = userId;
88+
}
8289
const completionPersistedRef = useRef(false);
8390
const persistCompletion = useCallback(() => {
84-
if (completionPersistedRef.current || !userId) {
91+
const accountId = openedForAccountRef.current;
92+
if (completionPersistedRef.current || !accountId || accountId !== userId) {
8593
return;
8694
}
8795
completionPersistedRef.current = true;
8896
void (async () => {
89-
const result = await settlePromise(() => markConnectOnboardingCompleted(userId));
97+
const result = await settlePromise(() => markConnectOnboardingCompleted(accountId));
9098
reportAtomCommandResult(result, { label: "connect onboarding completion" });
9199
})();
92100
}, [userId]);
@@ -172,14 +180,26 @@ function ConfiguredConnectOnboardingRouteScreen() {
172180
);
173181

174182
// Check authorization + link state once the locally saved connections are
175-
// ready. Loaded once per mount: reconnect churn in the registry must not
176-
// flip the section back to "checking" mid-interaction.
177-
const loadedRef = useRef(false);
183+
// ready. Bearer tokens can surface after the catalog is ready (prepared
184+
// connections load asynchronously), so reload when a connection appears
185+
// that was never checked — but never reload on removals or identity churn,
186+
// which must not flip the section back to "checking" mid-interaction.
187+
const loadedEnvironmentIdsRef = useRef<Set<string> | null>(null);
178188
useEffect(() => {
179-
if (isLoadingSavedConnection || loadedRef.current) {
189+
if (isLoadingSavedConnection) {
190+
return;
191+
}
192+
const loadedIds = loadedEnvironmentIdsRef.current;
193+
if (
194+
loadedIds !== null &&
195+
bearerConnections.every((connection) => loadedIds.has(connection.environmentId))
196+
) {
180197
return;
181198
}
182-
loadedRef.current = true;
199+
loadedEnvironmentIdsRef.current = new Set([
200+
...(loadedIds ?? []),
201+
...bearerConnections.map((connection) => connection.environmentId),
202+
]);
183203
void loadPublishTargets(bearerConnections);
184204
}, [bearerConnections, isLoadingSavedConnection, loadPublishTargets]);
185205

apps/mobile/src/features/cloud/connectOnboarding.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,38 @@ const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
1515
Atom.withLabel("mobile:connect-onboarding-request"),
1616
);
1717

18+
// The account CloudAuthBridge currently observes. Requests are only valid
19+
// while their account stays signed in.
20+
const connectOnboardingAccountAtom = Atom.make<string | null>(null).pipe(
21+
Atom.keepAlive,
22+
Atom.withLabel("mobile:connect-onboarding-account"),
23+
);
24+
25+
/**
26+
* Records the signed-in account observed by CloudAuthBridge. A sign-out or
27+
* account switch drops any pending onboarding request for another account —
28+
* including a request whose preference load is still in flight — so the sheet
29+
* can never present for an account that is no longer signed in.
30+
*/
31+
export function syncConnectOnboardingAccount(accountId: string | null): void {
32+
appAtomRegistry.set(connectOnboardingAccountAtom, accountId);
33+
const requested = appAtomRegistry.get(connectOnboardingRequestAtom);
34+
if (requested !== null && requested !== accountId) {
35+
clearConnectOnboardingRequest();
36+
}
37+
}
38+
1839
/**
1940
* Requests the onboarding sheet for the given account unless it already
2041
* completed (or skipped) onboarding on this device.
2142
*/
2243
export async function requestConnectOnboardingIfNeeded(accountId: string): Promise<void> {
2344
const preferences = await loadPreferences();
45+
// The account may have signed out (or been switched) while the preference
46+
// load was in flight; a stale request must not overwrite the current state.
47+
if (appAtomRegistry.get(connectOnboardingAccountAtom) !== accountId) {
48+
return;
49+
}
2450
if (preferences.connectOnboardingCompletedAccounts?.includes(accountId)) {
2551
return;
2652
}

apps/web/src/components/cloud/ConnectOnboardingDialog.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ import { toastManager } from "../ui/toast";
3030

3131
/**
3232
* Post-sign-in onboarding wizard for T3 Connect. Opens once per account (per
33-
* browser) after the user is signed in: first prompts to publish this
34-
* environment (managed tunnel + agent activity, both defaulting on) when the
35-
* current session is authorized to manage the relay link, then lists the
36-
* account's T3 Connect environments so every device can be connected right
37-
* away. Dismissing the dialog counts as completion — it never nags twice.
33+
* browser) after a sign-in completes during this session: first prompts to
34+
* publish this environment (managed tunnel + agent activity, both defaulting
35+
* on) when the current session is authorized to manage the relay link, then
36+
* lists the account's T3 Connect environments so every device can be
37+
* connected right away. Dismissing the dialog counts as completion — it never
38+
* nags twice.
3839
*/
3940
export function ConnectOnboardingDialog() {
4041
if (!hasCloudPublicConfig()) return null;
@@ -84,8 +85,28 @@ function ConfiguredConnectOnboardingDialog() {
8485

8586
const completedAccounts = onboardingState.completedAccounts;
8687

88+
// Only a sign-in that completed during this session prompts onboarding: a
89+
// cold start (a page load observing an already signed-in user) must not
90+
// re-prompt, matching the mobile trigger.
91+
const observedAccountRef = useRef<string | null | undefined>(undefined);
92+
const [signedInAccount, setSignedInAccount] = useState<string | null>(null);
93+
useEffect(() => {
94+
if (!isLoaded) return;
95+
const previousObservedAccount = observedAccountRef.current;
96+
const nextAccount = isSignedIn && userId ? userId : null;
97+
observedAccountRef.current = nextAccount;
98+
if (
99+
previousObservedAccount !== undefined &&
100+
previousObservedAccount !== nextAccount &&
101+
nextAccount !== null
102+
) {
103+
setSignedInAccount(nextAccount);
104+
}
105+
}, [isLoaded, isSignedIn, userId]);
106+
87107
useEffect(() => {
88108
if (!isLoaded || !isSignedIn || !userId) return;
109+
if (signedInAccount !== userId) return;
89110
if (openForAccount !== null) return;
90111
if (completedAccounts.includes(userId)) return;
91112
if (!sessionScopesKnown) return;
@@ -102,6 +123,7 @@ function ConfiguredConnectOnboardingDialog() {
102123
isSignedIn,
103124
openForAccount,
104125
sessionScopesKnown,
126+
signedInAccount,
105127
userId,
106128
]);
107129

0 commit comments

Comments
 (0)