From c987072da41133abe40359be76b9da6a47c3895a Mon Sep 17 00:00:00 2001 From: Rowan-Paul Date: Fri, 4 Sep 2026 20:44:09 +0200 Subject: [PATCH 1/2] fix(mobile): allow stale environments to be deregistered --- .../src/features/cloud/managedRelayState.ts | 22 +++++ .../connection/CloudEnvironmentRows.tsx | 94 ++++++++++++++++++- docs/user/remote-access.md | 5 +- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 8c41d74841e7..375bda715e12 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -1,9 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -19,6 +25,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), }); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "mobile:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index f4a5b531d026..aefd8cf2c1e9 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -4,15 +4,20 @@ import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { type EnvironmentId, type EnvironmentMachineKind, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; import { ActivityIndicator, + Alert, Pressable, type NativeSyntheticEvent, type TextLayoutEventData, @@ -29,7 +34,9 @@ import { serverEnvironment } from "../../state/server"; import { ProviderSetupLink } from "../settings/ProviderSetupLink"; import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { deregisterManagedRelayEnvironmentCommand } from "../cloud/managedRelayState"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -87,11 +94,17 @@ function CloudEnvironmentRowsContent( props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, ) { const controller = useConnectionController(); + const managedRelaySession = useAtomValue(managedRelaySessionAtom); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); const discoveryAvailable = props.discoveryAvailable ?? true; const availableCloudEnvironments = discoveryAvailable ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) : []; const [expandedErrorId, setExpandedErrorId] = useState(null); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -109,6 +122,47 @@ function CloudEnvironmentRowsContent( setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); }, []); + const handleDeregisterCloudEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => { + Alert.alert( + "Deregister environment?", + `Remove ${environment.label} from your T3 Connect account? This revokes its T3 Connect access and removes its managed tunnel.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Deregister", + style: "destructive", + onPress: async () => { + if (!managedRelaySession) { + Alert.alert( + "Could not deregister environment", + "Sign in to T3 Connect before deregistering an environment.", + ); + return; + } + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId: managedRelaySession.accountId, + environmentId: environment.environmentId, + }); + setDeregisteringEnvironmentId(null); + if (AsyncResult.isSuccess(result)) { + await controller.refreshRelayEnvironments(); + return; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not deregister environment", + error instanceof Error ? error.message : "The environment could not be removed.", + ); + }, + }, + ], + ); + }, + [controller, deregisterEnvironment, managedRelaySession], + ); + const showHeader = props.showHeader ?? true; return ( @@ -160,6 +214,8 @@ function CloudEnvironmentRowsContent( environment={environment} borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} + onDeregister={() => handleDeregisterCloudEnvironment(environment.environment)} + deregistering={deregisteringEnvironmentId === environment.environment.environmentId} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> @@ -264,8 +320,10 @@ function ConnectedCloudEnvironmentRow(props: { function CloudEnvironmentRow(props: { readonly environment: RelayEnvironmentView; readonly borderTop: boolean; + readonly deregistering: boolean; readonly errorExpanded: boolean; readonly onConnect: () => void; + readonly onDeregister: () => void; readonly onToggleError: () => void; }) { const presentation = availableCloudEnvironmentPresentation({ @@ -288,6 +346,8 @@ function CloudEnvironmentRow(props: { props.onConnect(); } }} + onDeregister={props.onDeregister} + deregistering={props.deregistering} onToggleError={props.onToggleError} statusText={presentation.statusText} value={false} @@ -300,12 +360,14 @@ function CloudEnvironmentRowShell(props: { readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; readonly connectionState: EnvironmentConnectionPhase; + readonly deregistering?: boolean; readonly disabled?: boolean; readonly errorExpanded: boolean; readonly label: string; /** Absent for environments the relay lists but this device has not connected to. */ readonly machine?: EnvironmentMachineKind; readonly onToggleError: () => void; + readonly onDeregister?: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; readonly value: boolean; @@ -428,11 +490,33 @@ function CloudEnvironmentRowShell(props: { ) : null} - + + + {props.onDeregister ? ( + + {props.deregistering ? ( + + ) : ( + + )} + + ) : null} + ); } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b2b540a83e2c..622df5a35f18 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -274,8 +274,9 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference ### Deregister a T3 Connect Environment Open your account menu and choose **T3 Connect** to see every environment registered to your -account. On mobile, open **Settings** → **T3 Connect**. Choose **Deregister** to revoke an -environment's T3 Connect access, remove any managed tunnel, and free its host space. +account. On mobile, open **Settings** → **Environments**, then choose **Deregister** beside the +environment under **T3 Connect**. This revokes the environment's T3 Connect access, removes any +managed tunnel, and frees its host space. Deregistration is an account action and does not need a connection to the environment, so it also works for a server that was wiped or is no longer reachable. Device-local connect and disconnect From b78cf0d506b2e9bb12e79a398e6eebf57713fecf Mon Sep 17 00:00:00 2001 From: Rowan-Paul Date: Fri, 4 Sep 2026 21:25:35 +0200 Subject: [PATCH 2/2] fix(mobile): refresh after deregister and keep queued rows disabled The relay discovery refresh command used single-flight mode, so a refresh requested after an unlink could join a pass that started before the unlink landed and leave the removed environment on screen. Switch it to `latest` mode so a mid-flight request queues one fresh pass after the current one settles. Deregistrations run serially per account, but only one pending ID was tracked, so a queued row re-enabled when the first tap settled. Track the pending IDs as a set and remove each when its own command settles. Co-Authored-By: Claude Fable 5.1 --- .../connection/CloudEnvironmentRows.tsx | 19 +++++-- .../src/state/relayDiscovery.test.ts | 51 +++++++++++++++++++ .../src/state/relayDiscovery.ts | 6 ++- 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 packages/client-runtime/src/state/relayDiscovery.test.ts diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index aefd8cf2c1e9..dbbfaaacb72a 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -103,8 +103,11 @@ function CloudEnvironmentRowsContent( ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) : []; const [expandedErrorId, setExpandedErrorId] = useState(null); - const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = - useState(null); + // Deregistrations run serially per account, so a second tap queues behind the + // first; every queued row stays disabled until its own command settles. + const [deregisteringEnvironmentIds, setDeregisteringEnvironmentIds] = useState< + ReadonlySet + >(() => new Set()); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -140,12 +143,18 @@ function CloudEnvironmentRowsContent( ); return; } - setDeregisteringEnvironmentId(environment.environmentId); + setDeregisteringEnvironmentIds((current) => + new Set(current).add(environment.environmentId), + ); const result = await deregisterEnvironment({ accountId: managedRelaySession.accountId, environmentId: environment.environmentId, }); - setDeregisteringEnvironmentId(null); + setDeregisteringEnvironmentIds((current) => { + const next = new Set(current); + next.delete(environment.environmentId); + return next; + }); if (AsyncResult.isSuccess(result)) { await controller.refreshRelayEnvironments(); return; @@ -215,7 +224,7 @@ function CloudEnvironmentRowsContent( borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} onDeregister={() => handleDeregisterCloudEnvironment(environment.environment)} - deregistering={deregisteringEnvironmentId === environment.environment.environmentId} + deregistering={deregisteringEnvironmentIds.has(environment.environment.environmentId)} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> diff --git a/packages/client-runtime/src/state/relayDiscovery.test.ts b/packages/client-runtime/src/state/relayDiscovery.test.ts new file mode 100644 index 000000000000..f7de9793e968 --- /dev/null +++ b/packages/client-runtime/src/state/relayDiscovery.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + RelayEnvironmentDiscovery, +} from "../relay/discovery.ts"; +import { createRelayEnvironmentDiscoveryAtoms } from "./relayDiscovery.ts"; + +describe("createRelayEnvironmentDiscoveryAtoms", () => { + it("runs a fresh refresh after the in-flight one when requested mid-flight", async () => { + const firstRefresh = Latch.makeUnsafe(); + let markFirstRefreshStarted!: () => void; + const firstRefreshStarted = new Promise((resolve) => { + markFirstRefreshStarted = resolve; + }); + let refreshes = 0; + const discoveryLayer = Layer.effect( + RelayEnvironmentDiscovery, + Effect.gen(function* () { + const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + return RelayEnvironmentDiscovery.of({ + state, + refresh: Effect.suspend(() => { + refreshes += 1; + if (refreshes !== 1) return Effect.void; + markFirstRefreshStarted(); + return firstRefresh.await; + }), + }); + }), + ); + const atoms = createRelayEnvironmentDiscoveryAtoms(Atom.runtime(discoveryLayer)); + const registry = AtomRegistry.make(); + + const first = atoms.refresh.run(registry, undefined); + await firstRefreshStarted; + // Simulates a relay mutation that lands while the first pass is running. + const second = atoms.refresh.run(registry, undefined); + firstRefresh.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success" }); + expect(await second).toMatchObject({ _tag: "Success" }); + expect(refreshes).toBe(2); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index bdf217d08800..41de849a7268 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -24,9 +24,13 @@ export function createRelayEnvironmentDiscoveryAtoms( () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); + // `latest` rather than `singleFlight`: a refresh requested while one is in + // flight must start a fresh pass once it settles. Callers refresh after + // mutating the relay (linking, deregistering), and joining a pass that began + // before the mutation landed would show the stale list as the final result. const refresh = createRuntimeCommand(runtime, { label: "relay-environment-discovery:refresh", - concurrency: { mode: "singleFlight", key: () => "refresh" }, + concurrency: { mode: "latest", key: () => "refresh" }, execute: (_input: void) => RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( Effect.flatMap((discovery) => discovery.refresh),