diff --git a/apps/web/src/cloud/connectOnboarding.test.ts b/apps/web/src/cloud/connectOnboarding.test.ts new file mode 100644 index 000000000000..fc17028f4806 --- /dev/null +++ b/apps/web/src/cloud/connectOnboarding.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveOnboardingManagedTunnelActive, + resolveOnboardingReconcileDesired, + shouldSyncOnboardingToggleFromLinkState, +} from "./connectOnboarding"; + +describe("shouldSyncOnboardingToggleFromLinkState", () => { + it("resyncs an untouched toggle for the account being onboarded", () => { + expect( + shouldSyncOnboardingToggleFromLinkState({ + touched: false, + openForAccount: "user-1", + linked: true, + cloudUserId: "user-1", + }), + ).toBe(true); + }); + + it("does not overwrite a toggle the user has already changed", () => { + expect( + shouldSyncOnboardingToggleFromLinkState({ + touched: true, + openForAccount: "user-1", + linked: true, + cloudUserId: "user-1", + }), + ).toBe(false); + }); + + it("ignores link state from another account", () => { + expect( + shouldSyncOnboardingToggleFromLinkState({ + touched: false, + openForAccount: "user-1", + linked: true, + cloudUserId: "user-2", + }), + ).toBe(false); + }); + + it("does not sync when the wizard is closed or the environment is unlinked", () => { + expect( + shouldSyncOnboardingToggleFromLinkState({ + touched: false, + openForAccount: null, + linked: true, + cloudUserId: "user-1", + }), + ).toBe(false); + expect( + shouldSyncOnboardingToggleFromLinkState({ + touched: false, + openForAccount: "user-1", + linked: false, + cloudUserId: "user-1", + }), + ).toBe(false); + }); +}); + +describe("resolveOnboardingManagedTunnelActive", () => { + it("uses the current tunnel only when the cached link belongs to this account", () => { + expect( + resolveOnboardingManagedTunnelActive({ + managedTunnelActive: true, + cloudUserId: "user-1", + openForAccount: "user-1", + }), + ).toBe(true); + }); + + it("ignores a previous account's active tunnel after an account switch", () => { + expect( + resolveOnboardingManagedTunnelActive({ + managedTunnelActive: true, + cloudUserId: "user-1", + openForAccount: "user-2", + }), + ).toBe(false); + expect( + resolveOnboardingReconcileDesired({ + exposeEnvironment: false, + publishAgentActivity: true, + managedTunnelActive: resolveOnboardingManagedTunnelActive({ + managedTunnelActive: true, + cloudUserId: "user-1", + openForAccount: "user-2", + }), + }), + ).toEqual({ managedTunnel: false, publish: true }); + }); +}); + +describe("resolveOnboardingReconcileDesired", () => { + it("does not tear down an active tunnel after a stale inactive prefill", () => { + expect( + resolveOnboardingReconcileDesired({ + exposeEnvironment: false, + publishAgentActivity: true, + managedTunnelActive: true, + }), + ).toEqual({ managedTunnel: true, publish: true }); + }); + + it("skips reconcile when nothing would be enabled", () => { + expect( + resolveOnboardingReconcileDesired({ + exposeEnvironment: false, + publishAgentActivity: false, + managedTunnelActive: false, + }), + ).toBeNull(); + }); + + it("does not rewrite publish when both toggles are off and a tunnel is already active", () => { + expect( + resolveOnboardingReconcileDesired({ + exposeEnvironment: false, + publishAgentActivity: false, + managedTunnelActive: true, + }), + ).toBeNull(); + }); + + it("keeps an explicit request to enable the managed tunnel", () => { + expect( + resolveOnboardingReconcileDesired({ + exposeEnvironment: true, + publishAgentActivity: false, + managedTunnelActive: false, + }), + ).toEqual({ managedTunnel: true, publish: false }); + }); +}); diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts index 7d61e11c6c6a..9cd417752ddd 100644 --- a/apps/web/src/cloud/connectOnboarding.ts +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -16,3 +16,45 @@ export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema. export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { optOutAccounts: [], }; + +export function shouldSyncOnboardingToggleFromLinkState(input: { + readonly touched: boolean; + readonly openForAccount: string | null; + readonly linked: boolean; + readonly cloudUserId: string | null; +}): boolean { + return ( + !input.touched && + input.openForAccount !== null && + input.linked && + input.cloudUserId === input.openForAccount + ); +} + +// controller.managedTunnelActive is environment-global. After an account +// switch the cached link can still belong to the previous user — only treat +// it as this wizard's tunnel when the linked user matches. +export function resolveOnboardingManagedTunnelActive(input: { + readonly managedTunnelActive: boolean; + readonly cloudUserId: string | null | undefined; + readonly openForAccount: string | null; +}): boolean { + return input.cloudUserId === input.openForAccount ? input.managedTunnelActive : false; +} + +// The wizard only ever enables. Both toggles off means skip — do not unlink, +// downgrade, or rewrite publish. A stale expose=false after startup +// reconcile must also not submit publish_only and tear the tunnel down. +export function resolveOnboardingReconcileDesired(input: { + readonly exposeEnvironment: boolean; + readonly publishAgentActivity: boolean; + readonly managedTunnelActive: boolean; +}): { readonly managedTunnel: boolean; readonly publish: boolean } | null { + if (!input.exposeEnvironment && !input.publishAgentActivity) { + return null; + } + return { + managedTunnel: input.exposeEnvironment || input.managedTunnelActive, + publish: input.publishAgentActivity, + }; +} diff --git a/apps/web/src/cloud/primaryCloudLinkState.test.ts b/apps/web/src/cloud/primaryCloudLinkState.test.ts new file mode 100644 index 000000000000..de1c608e8c9d --- /dev/null +++ b/apps/web/src/cloud/primaryCloudLinkState.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { CloudLinkTarget } from "./linkEnvironment"; +import { + __resetStartupCloudLinkRefreshForTests, + CONNECTIONS_CLOUD_LINK_RETRY_BUDGET_MS, + resolveManagedTunnelActive, + scheduleStartupReconcileLinkStateRefresh, + shouldContinueConnectionsCloudLinkRetry, + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS, + stopStartupReconcileLinkStateRefresh, +} from "./primaryCloudLinkState"; + +const TARGET: CloudLinkTarget = { + environmentId: "environment-1", + label: "Desktop", + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", +}; + +describe("resolveManagedTunnelActive", () => { + it("falls back to linked when older servers omit managedTunnelActive", () => { + expect(resolveManagedTunnelActive({ linked: true })).toBe(true); + expect(resolveManagedTunnelActive({ linked: false })).toBe(false); + }); + + it("does not treat a publish-only or pre-reconcile link as an active tunnel", () => { + expect(resolveManagedTunnelActive({ linked: true, managedTunnelActive: false })).toBe(false); + }); + + it("is inactive when link state has not loaded", () => { + expect(resolveManagedTunnelActive(null)).toBe(false); + expect(resolveManagedTunnelActive(undefined)).toBe(false); + }); +}); + +describe("shouldContinueConnectionsCloudLinkRetry", () => { + it("stops once the managed tunnel is active", () => { + expect(shouldContinueConnectionsCloudLinkRetry(0, true)).toBe(false); + }); + + it("retries while inactive inside the budget and stops after it", () => { + expect(shouldContinueConnectionsCloudLinkRetry(0, false)).toBe(true); + expect( + shouldContinueConnectionsCloudLinkRetry(CONNECTIONS_CLOUD_LINK_RETRY_BUDGET_MS - 1, false), + ).toBe(true); + expect( + shouldContinueConnectionsCloudLinkRetry(CONNECTIONS_CLOUD_LINK_RETRY_BUDGET_MS, false), + ).toBe(false); + }); +}); + +describe("scheduleStartupReconcileLinkStateRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + __resetStartupCloudLinkRefreshForTests(); + }); + + afterEach(() => { + vi.useRealTimers(); + __resetStartupCloudLinkRefreshForTests(); + }); + + it("replaces a startup-stale inactive switch across the bounded refresh series", () => { + let state: { linked: boolean; managedTunnelActive?: boolean } = { + linked: true, + managedTunnelActive: false, + }; + const refresh = vi.fn(() => { + if (refresh.mock.calls.length >= 2) { + state = { linked: true, managedTunnelActive: true }; + } + }); + + expect(resolveManagedTunnelActive(state)).toBe(false); + expect(scheduleStartupReconcileLinkStateRefresh(TARGET, refresh)).toBe(true); + expect(scheduleStartupReconcileLinkStateRefresh(TARGET, refresh)).toBe(false); + + vi.advanceTimersByTime(STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[0] - 1); + expect(refresh).not.toHaveBeenCalled(); + expect(resolveManagedTunnelActive(state)).toBe(false); + + vi.advanceTimersByTime(1); + expect(refresh).toHaveBeenCalledTimes(1); + expect(resolveManagedTunnelActive(state)).toBe(false); + + vi.advanceTimersByTime( + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[1] - + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[0], + ); + expect(refresh).toHaveBeenCalledTimes(2); + expect(resolveManagedTunnelActive(state)).toBe(true); + + vi.advanceTimersByTime( + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[2] - + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[1], + ); + expect(refresh).toHaveBeenCalledTimes(3); + expect(refresh).toHaveBeenNthCalledWith(1, TARGET); + expect(refresh).toHaveBeenNthCalledWith(2, TARGET); + expect(refresh).toHaveBeenNthCalledWith(3, TARGET); + }); + + it("cancels remaining startup refreshes once the tunnel is active", () => { + const refresh = vi.fn(); + expect(scheduleStartupReconcileLinkStateRefresh(TARGET, refresh)).toBe(true); + + vi.advanceTimersByTime(STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[0]); + expect(refresh).toHaveBeenCalledTimes(1); + + stopStartupReconcileLinkStateRefresh(TARGET); + vi.advanceTimersByTime(STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[2]); + expect(refresh).toHaveBeenCalledTimes(1); + expect(scheduleStartupReconcileLinkStateRefresh(TARGET, refresh)).toBe(false); + }); + + it("does not schedule without a target", () => { + const refresh = vi.fn(); + expect(scheduleStartupReconcileLinkStateRefresh(null, refresh)).toBe(false); + vi.advanceTimersByTime(STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[2]); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("schedules a separate follow-up for a different environment", () => { + const refresh = vi.fn(); + const otherTarget = { ...TARGET, environmentId: "environment-2" }; + + expect(scheduleStartupReconcileLinkStateRefresh(TARGET, refresh)).toBe(true); + expect(scheduleStartupReconcileLinkStateRefresh(otherTarget, refresh)).toBe(true); + + vi.advanceTimersByTime(STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS[0]); + expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenNthCalledWith(1, TARGET); + expect(refresh).toHaveBeenNthCalledWith(2, otherTarget); + }); +}); diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..f17d5713324e 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { HttpClient } from "effect/unstable/http"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { usePrimaryEnvironment } from "../state/environments"; import { runtime } from "../lib/runtime"; @@ -48,6 +48,84 @@ export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): vo } } +// Older environment servers predate the managedTunnelActive field; for them a +// link always implies a managed tunnel, so fall back to `linked`. +export function resolveManagedTunnelActive( + state: Pick | null | undefined, +): boolean { + return state?.managedTunnelActive ?? state?.linked ?? false; +} + +// Startup reconcile applies relay config after routes are already live. The +// first link-state read can cache `managedTunnelActive: false`; a short +// bounded series of refreshes replaces that once reconcile has had a chance +// to finish. Delays are from first settlement, then we stop. +export const STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS = [2_000, 5_000, 10_000] as const; +export const CONNECTIONS_CLOUD_LINK_RETRY_INTERVAL_MS = 2_000; +export const CONNECTIONS_CLOUD_LINK_RETRY_BUDGET_MS = 15_000; + +const scheduledStartupReconcileRefreshKeys = new Set(); +const pendingStartupReconcileRefreshTimers = new Map< + string, + Array> +>(); + +export function shouldContinueConnectionsCloudLinkRetry( + elapsedMs: number, + managedTunnelActive: boolean, +): boolean { + return !managedTunnelActive && elapsedMs < CONNECTIONS_CLOUD_LINK_RETRY_BUDGET_MS; +} + +export function scheduleStartupReconcileLinkStateRefresh( + target: CloudLinkTarget | null, + refresh: (target: CloudLinkTarget | null) => void = refreshPrimaryCloudLinkState, +): boolean { + if (!target) { + return false; + } + const key = targetKey(target); + if (scheduledStartupReconcileRefreshKeys.has(key)) { + return false; + } + scheduledStartupReconcileRefreshKeys.add(key); + pendingStartupReconcileRefreshTimers.set( + key, + STARTUP_CLOUD_LINK_RECONCILE_REFRESH_DELAYS_MS.map((delayMs) => + setTimeout(() => { + refresh(target); + }, delayMs), + ), + ); + return true; +} + +export function stopStartupReconcileLinkStateRefresh(target: CloudLinkTarget | null): void { + if (!target) { + return; + } + const key = targetKey(target); + scheduledStartupReconcileRefreshKeys.add(key); + const timers = pendingStartupReconcileRefreshTimers.get(key); + if (!timers) { + return; + } + for (const timer of timers) { + clearTimeout(timer); + } + pendingStartupReconcileRefreshTimers.delete(key); +} + +export function __resetStartupCloudLinkRefreshForTests(): void { + for (const timers of pendingStartupReconcileRefreshTimers.values()) { + for (const timer of timers) { + clearTimeout(timer); + } + } + pendingStartupReconcileRefreshTimers.clear(); + scheduledStartupReconcileRefreshKeys.clear(); +} + export function usePrimaryCloudLinkState() { const primary = usePrimaryEnvironment(); const target = useMemo( @@ -69,6 +147,22 @@ export function usePrimaryCloudLinkState() { const refresh = useCallback(() => { refreshPrimaryCloudLinkState(target); }, [target]); + const settled = result._tag === "Success" || result._tag === "Failure"; + const data = Option.getOrNull(AsyncResult.value(result)); + const managedTunnelActive = resolveManagedTunnelActive(data); + useEffect(() => { + if (!target) { + return; + } + if (managedTunnelActive) { + stopStartupReconcileLinkStateRefresh(target); + return; + } + if (!settled) { + return; + } + scheduleStartupReconcileLinkStateRefresh(target); + }, [managedTunnelActive, settled, target]); let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); @@ -76,7 +170,7 @@ export function usePrimaryCloudLinkState() { } return { - data: Option.getOrNull(AsyncResult.value(result)), + data, error, isPending: result.waiting, refresh, diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d91596880850..b0ce31921526 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -15,7 +15,7 @@ import { unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "./linkEnvironmentAtoms"; -import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { resolveManagedTunnelActive, usePrimaryCloudLinkState } from "./primaryCloudLinkState"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; export interface CloudLinkDesiredState { @@ -70,10 +70,7 @@ export function useCloudLinkController() { }); }; - // Older environment servers predate the managedTunnelActive field; for them a - // link always implies a managed tunnel, so fall back to `linked`. - const managedTunnelActive = - primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; + const managedTunnelActive = resolveManagedTunnelActive(primaryCloudLinkState.data); const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; const linked = primaryCloudLinkState.data?.linked ?? false; diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 6248fb00bcfe..4940f97c5f48 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -7,7 +7,11 @@ import { CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, + resolveOnboardingManagedTunnelActive, + resolveOnboardingReconcileDesired, + shouldSyncOnboardingToggleFromLinkState, } from "~/cloud/connectOnboarding"; +import { resolveManagedTunnelActive } from "~/cloud/primaryCloudLinkState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { usePrimarySessionState } from "~/environments/primary"; @@ -86,7 +90,8 @@ function ConfiguredConnectOnboardingDialog() { const [publishAgentActivity, setPublishAgentActivity] = useState(true); const [dontShowAgain, setDontShowAgain] = useState(false); const [isApplying, setIsApplying] = useState(false); - const prefilledFromLinkStateRef = useRef(false); + const exposeTouchedRef = useRef(false); + const publishTouchedRef = useRef(false); const observedAccountRef = useRef(undefined); const optOutAccounts = optOutState.optOutAccounts; @@ -125,7 +130,8 @@ function ConfiguredConnectOnboardingDialog() { } if (!sessionScopesKnown || !publishStepDecided) return; setRequestedAccount(null); - prefilledFromLinkStateRef.current = false; + exposeTouchedRef.current = false; + publishTouchedRef.current = false; setExposeEnvironment(true); setPublishAgentActivity(true); setDontShowAgain(false); @@ -154,17 +160,29 @@ function ConfiguredConnectOnboardingDialog() { // Toggles default on, but an environment that is already linked should show // its actual configuration instead of silently proposing to rewrite it. + // Keep resyncing from link state until the user touches a control — a + // startup-stale inactive snapshot must not stick after reconcile. // Only when the link belongs to the account being onboarded, though — after // an account switch the cached link state can still describe the previous // account's setup. const linkStateData = controller.linkState.data; useEffect(() => { - if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) { + if (openForAccount === null || linkStateData === null) { return; } - prefilledFromLinkStateRef.current = true; - if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) { - setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); + const syncInput = { + openForAccount, + linked: linkStateData.linked, + cloudUserId: linkStateData.cloudUserId, + }; + if ( + shouldSyncOnboardingToggleFromLinkState({ ...syncInput, touched: exposeTouchedRef.current }) + ) { + setExposeEnvironment(resolveManagedTunnelActive(linkStateData)); + } + if ( + shouldSyncOnboardingToggleFromLinkState({ ...syncInput, touched: publishTouchedRef.current }) + ) { setPublishAgentActivity(linkStateData.publishAgentActivity); } }, [linkStateData, openForAccount]); @@ -186,22 +204,29 @@ function ConfiguredConnectOnboardingDialog() { const applyPublishSelection = async () => { // The wizard only ever enables — with both toggles off there is nothing to - // apply, and an existing link must not be torn down from onboarding. - if (!exposeEnvironment && !publishAgentActivity) { + // apply, and an existing managed tunnel must not be torn down from + // onboarding if a later link-state refresh showed it active. + const desired = resolveOnboardingReconcileDesired({ + exposeEnvironment, + publishAgentActivity, + managedTunnelActive: resolveOnboardingManagedTunnelActive({ + managedTunnelActive: controller.managedTunnelActive, + cloudUserId: linkStateData?.cloudUserId, + openForAccount, + }), + }); + if (desired === null) { setStep("devices"); return; } setIsApplying(true); - const ok = await controller.reconcileCloudState({ - managedTunnel: exposeEnvironment, - publish: publishAgentActivity, - }); + const ok = await controller.reconcileCloudState(desired); setIsApplying(false); if (!ok) return; toastManager.add({ type: "success", title: "T3 Connect enabled", - description: exposeEnvironment + description: desired.managedTunnel ? "This environment is available to your other devices through T3 Connect." : "This environment publishes agent activity to your mobile clients.", }); @@ -240,8 +265,14 @@ function ConfiguredConnectOnboardingDialog() { publishAgentActivity={publishAgentActivity} disabled={isApplying} operationError={controller.operationError} - onExposeEnvironmentChange={setExposeEnvironment} - onPublishAgentActivityChange={setPublishAgentActivity} + onExposeEnvironmentChange={(enabled) => { + exposeTouchedRef.current = true; + setExposeEnvironment(enabled); + }} + onPublishAgentActivityChange={(enabled) => { + publishTouchedRef.current = true; + setPublishAgentActivity(enabled); + }} /> ) : ( diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 290e2daa12b8..b541e3b17ab9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -2,6 +2,7 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, + isInitialCloudLinkStatePending, isQrShareableEndpoint, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; @@ -15,6 +16,20 @@ const baseWslState: DesktopWslState = { preflightError: null, }; +describe("isInitialCloudLinkStatePending", () => { + it("treats SWR revalidation of a cached snapshot as ready", () => { + expect(isInitialCloudLinkStatePending({ isPending: true, data: { linked: true } })).toBe(false); + expect(isInitialCloudLinkStatePending({ isPending: false, data: { linked: true } })).toBe( + false, + ); + }); + + it("is pending only before the first link-state value arrives", () => { + expect(isInitialCloudLinkStatePending({ isPending: true, data: null })).toBe(true); + expect(isInitialCloudLinkStatePending({ isPending: false, data: null })).toBe(false); + }); +}); + describe("applyWslEnableSelection", () => { it("clears WSL-only and updates the distro before enabling both backends", async () => { const calls: Array = []; diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c7543..6c790dc439d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -2,6 +2,15 @@ import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tool type WslEnableBridge = Pick; +// SWR `waiting` is true on every refresh. Disable the T3 Connect switches +// only for the first load, not while a cached value is being revalidated. +export function isInitialCloudLinkStatePending(input: { + readonly isPending: boolean; + readonly data: unknown; +}): boolean { + return input.isPending && input.data === null; +} + /** * A QR code encoding a loopback URL makes the scanning device dial itself, so * loopback endpoints stay copyable from the endpoint menu but are never diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 822dab797fea..5ba36d436bbc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,7 +6,7 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useEffect, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -42,6 +42,7 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection, + isInitialCloudLinkStatePending, isQrShareableEndpoint, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; @@ -104,6 +105,10 @@ import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, } from "~/versionSkew"; +import { + CONNECTIONS_CLOUD_LINK_RETRY_INTERVAL_MS, + shouldContinueConnectionsCloudLinkRetry, +} from "~/cloud/primaryCloudLinkState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; @@ -1600,6 +1605,31 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b operationError, reconcileCloudState, } = useCloudLinkController(); + // Opening Connections can race startup reconcile: the shared SWR atom may + // still hold managedTunnelActive: false from a pre-reconcile read. Refresh + // on open, then retry on a short budget while a linked tunnel still looks + // inactive and this page stays mounted. + const linked = primaryCloudLinkState.data?.linked ?? false; + useEffect(() => { + primaryCloudLinkState.refresh(); + }, [primaryCloudLinkState.refresh]); + useEffect(() => { + if (managedTunnelActive || !linked) { + return; + } + const startedAt = Date.now(); + const intervalId = window.setInterval(() => { + const elapsedMs = Date.now() - startedAt; + if (!shouldContinueConnectionsCloudLinkRetry(elapsedMs, managedTunnelActive)) { + window.clearInterval(intervalId); + return; + } + primaryCloudLinkState.refresh(); + }, CONNECTIONS_CLOUD_LINK_RETRY_INTERVAL_MS); + return () => { + window.clearInterval(intervalId); + }; + }, [linked, managedTunnelActive, primaryCloudLinkState.refresh]); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); @@ -1609,6 +1639,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b ? "Your session does not have permission to manage T3 Connect access." : null; const isBusy = isUpdating || isUpdatingPreference; + const isInitialLinkStatePending = isInitialCloudLinkStatePending(primaryCloudLinkState); const updateManagedTunnel = async (enabled: boolean) => { setIsUpdating(true); @@ -1662,7 +1693,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b control={ void updateManagedTunnel(enabled)} /> @@ -1676,7 +1707,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b void updatePublishAgentActivity(enabled)} />