From 7e991f97b36229b3effa7b12751b95509897ac21 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:59:35 +1100 Subject: [PATCH] perf(mobile): share Settings environment derivation --- apps/mobile/src/state/environments.test.tsx | 379 ++++++++++++++++++++ apps/mobile/src/state/environments.ts | 17 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/state/environments.test.tsx diff --git a/apps/mobile/src/state/environments.test.tsx b/apps/mobile/src/state/environments.test.tsx new file mode 100644 index 0000000000000..df22e1a91f175 --- /dev/null +++ b/apps/mobile/src/state/environments.test.tsx @@ -0,0 +1,379 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + BearerConnectionProfile, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + type EnvironmentPresentation as SourcePresentation, + type NetworkStatus, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const sources = await vi.hoisted(async () => { + const { Atom } = await import("effect/reactivity"); + const presentations = Atom.make>(new Map()); + const catalog = Atom.make({ isReady: false, entries: new Map() }); + const network = Atom.make("unknown"); + const lifecycle = { acquired: 0, released: 0 }; + function tracked(source: Atom.Atom) { + return Atom.make((get) => { + lifecycle.acquired++; + get.addFinalizer(() => lifecycle.released++); + return get(source); + }); + } + return { + presentations, + catalog, + network, + lifecycle, + presentationsAtom: tracked(presentations), + catalogValueAtom: tracked(catalog), + networkStatusValueAtom: tracked(network), + projectsAtom: Atom.make([]), + }; +}); + +// Replace service-backed source atoms, leaving the production hooks and registry intact. +vi.mock("./presentation", () => ({ + environmentPresentations: { presentationsAtom: sources.presentationsAtom }, +})); +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { + catalogValueAtom: sources.catalogValueAtom, + networkStatusValueAtom: sources.networkStatusValueAtom, + }, +})); +vi.mock("./projects", () => ({ environmentProjects: { projectsAtom: sources.projectsAtom } })); +vi.mock("./preferences", () => ({ mobilePreferencesAtom: Atom.make(AsyncResult.initial()) })); +vi.mock("./threads", () => ({ environmentThreadDetails: {}, environmentThreadShells: {} })); +vi.mock("./server", () => ({ + serverEnvironment: {}, + environmentServerConfigsAtom: Atom.make(new Map()), +})); +vi.mock("./assets", () => ({ projectFaviconUrlAtom: vi.fn() })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("../native/ProjectChipIconImages", () => ({ + projectGlyphImageAtom: vi.fn(), + projectGlyphKey: vi.fn(), +})); + +import { useEnvironments } from "./environments"; +import { + SettingsEnvironmentFilterProvider, + useSettingsEnvironmentFilter, +} from "../features/settings/settings-environment-filter"; + +type EnvironmentView = ReturnType; +type FilterView = ReturnType; + +function presentation(index: number, version = 0): SourcePresentation { + const environmentId = EnvironmentId.make(`environment-${index}`); + return { + entry: { + target: new PrimaryConnectionTarget({ + environmentId, + label: `Machine ${index} version ${version}`, + httpBaseUrl: `https://machine-${index}.example.test`, + wsBaseUrl: `wss://machine-${index}.example.test`, + }), + profile: Option.none(), + enabled: true, + }, + connection: { phase: index === 7 ? "offline" : "connected", error: null, traceId: null }, + // These consumers only test presence and pass the config through unchanged. + serverConfig: + index === 6 + ? null + : ({ cwd: `/workspace-${version}` } as NonNullable), + }; +} + +function entries(count = 8, version = 0) { + return Array.from({ length: count }, (_, index) => { + const value = presentation(index, version); + return [value.entry.target.environmentId, value] as const; + }); +} + +function setup(initial = new Map(entries())) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + sources.lifecycle.acquired = 0; + sources.lifecycle.released = 0; + const tasks = new Set<() => void>(); + const registry = AtomRegistry.make({ + initialValues: [[sources.presentations, initial]], + scheduleTask: (task) => { + tasks.add(task); + return () => tasks.delete(task); + }, + }); + const flush = () => { + for (const task of tasks) { + tasks.delete(task); + task(); + } + }; + const environments = new Map(); + const filters = new Map(); + function EnvironmentReader({ id }: { id: number }) { + const environment = useEnvironments(); + useLayoutEffect(() => { + environments.set(id, environment); + }); + return null; + } + function Reader({ id, includeEnvironments }: { id: number; includeEnvironments: boolean }) { + const filter = useSettingsEnvironmentFilter(); + useLayoutEffect(() => { + filters.set(id, filter); + }); + return includeEnvironments ? : null; + } + const root = createRoot(document.createElement("div")); + return { + registry, + environments, + filters, + render(readers = 3, open = true, includeEnvironments = true) { + act(() => { + root.render( + + {Array.from({ length: 3 }, (_, id) => ( + + {id < readers && } + + ))} + , + ); + }); + act(flush); + }, + update(next: ReadonlyMap) { + act(() => { + registry.set(sources.presentations, next); + flush(); + }); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + flush(); + registry.dispose(); + }, + }; +} + +it.each([1, 3])("projects and filters each source version once with %s readers", (readers) => { + const counts = { mapped: 0, filtered: 0 }; + class CountedMap extends Map { + override *entries(): MapIterator<[EnvironmentId, SourcePresentation]> { + for (const entry of super.entries()) { + counts.mapped++; + yield entry; + } + } + } + const version = (revision: number) => + new CountedMap( + entries(8, revision).map(([id, value]) => [ + id, + { + ...value, + connection: { + ...value.connection, + get phase() { + counts.filtered++; + return revision === 10 && id === EnvironmentId.make("environment-0") + ? "offline" + : value.connection.phase; + }, + }, + }, + ]), + ); + using screen = setup(version(0)); + screen.render(readers, true, false); + for (let revision = 1; revision < 11; revision++) screen.update(version(revision)); + const beforeReread = { ...counts }; + act(() => { + screen.registry.set(sources.catalog, { isReady: true, entries: new Map() }); + screen.registry.set(sources.network, "offline"); + }); + screen.render(readers, true, false); + expect(counts).toEqual(beforeReread); + console.info("environment projection operations", { readers, ...counts }); + expect(counts).toEqual({ mapped: 88, filtered: 88 }); + expect(screen.filters.get(0)!.availableTargets).toHaveLength(5); +}); + +it("shares environments and connected targets while preserving independent Settings scopes", () => { + using screen = setup(); + screen.render(); + const first = screen.environments.get(0)!; + const firstTargets = screen.filters.get(0)!.availableTargets; + for (const id of [1, 2]) { + expect(screen.environments.get(id)!.environments).toBe(first.environments); + expect(screen.filters.get(id)!.availableTargets).toBe(firstTargets); + } + const excluded = firstTargets[0]!.environmentId; + act(() => screen.filters.get(0)!.toggleEnvironment(excluded)); + expect( + screen.filters.get(0)!.selectedTargets.map((target) => target.environmentId), + ).not.toContain(excluded); + expect(screen.filters.get(1)!.selectedIds).toBeNull(); + expect(screen.filters.get(1)!.selectedTargets).toContain(firstTargets[0]); + act(() => screen.filters.get(0)!.selectProject("page-project")); + expect(screen.filters.get(0)!.selectedProjectKey).toBe("page-project"); + expect(screen.filters.get(1)!.selectedProjectKey).toBeNull(); + act(() => screen.filters.get(0)!.selectAll()); + expect(screen.filters.get(0)!.selectedTargets).toEqual(firstTargets); + screen.render(3, false); + expect(screen.filters.get(0)!.selectedProjectKey).toBeNull(); +}); + +it("passes through presentation changes and map order, including empty and disconnected targets", () => { + const initial = new Map(entries(2)); + using screen = setup(initial); + screen.render(); + const [firstId, secondId] = [...initial.keys()]; + const first = initial.get(firstId!)!; + const other = initial.get(secondId!)!; + const check = ( + next: Map, + connected: readonly EnvironmentId[], + ) => { + screen.update(next); + for (const id of [0, 1, 2]) { + const view = screen.environments.get(id)!; + expect(view.presentationById).toBe(next); + expect(view.environments.map((entry) => entry.environmentId)).toEqual([...next.keys()]); + expect(screen.filters.get(id)!.availableTargets.map((entry) => entry.environmentId)).toEqual( + connected, + ); + for (const environment of view.environments) { + const source = next.get(environment.environmentId)!; + expect(environment.entry).toBe(source.entry); + expect(environment.connection).toBe(source.connection); + expect(environment.serverConfig).toBe(source.serverConfig); + } + } + }; + const added = presentation(2); + check(new Map([...initial, [added.entry.target.environmentId, added]]), [ + firstId!, + secondId!, + added.entry.target.environmentId, + ]); + check( + new Map([ + [secondId!, other], + [firstId!, first], + ]), + [secondId!, firstId!], + ); + const bearer = new BearerConnectionTarget({ + environmentId: firstId!, + label: "Renamed", + connectionId: "direct", + }); + const profile = new BearerConnectionProfile({ + environmentId: firstId!, + label: "Direct", + connectionId: "direct", + httpBaseUrl: "https://new.example.test", + wsBaseUrl: "wss://new.example.test", + }); + const changed: SourcePresentation = { + ...first, + entry: { + target: bearer, + profile: Option.some(profile), + enabled: false, + alternateRoutes: [ + { + target: new RelayConnectionTarget({ environmentId: firstId!, label: "Relay" }), + profile: Option.none(), + }, + ], + }, + serverConfig: { ...first.serverConfig!, cwd: "/changed" }, + }; + check(new Map([[firstId!, changed]]), [firstId!]); + expect(screen.environments.get(0)!.environments[0]).toMatchObject({ + label: "Renamed", + displayUrl: "https://new.example.test", + relayManaged: true, + }); + const rerouted: SourcePresentation = { + ...changed, + entry: { + ...changed.entry, + profile: Option.some( + new BearerConnectionProfile({ ...profile, httpBaseUrl: "https://route.example.test" }), + ), + alternateRoutes: [], + }, + }; + check(new Map([[firstId!, rerouted]]), [firstId!]); + expect(screen.environments.get(0)!.environments[0]).toMatchObject({ + displayUrl: "https://route.example.test", + relayManaged: false, + }); + const offline: SourcePresentation = { + ...rerouted, + connection: { phase: "offline", error: null, traceId: null }, + }; + check( + new Map([ + [firstId!, offline], + [secondId!, { ...other, serverConfig: null }], + ]), + [], + ); + check( + new Map([ + [firstId!, rerouted], + [secondId!, other], + ]), + [firstId!, secondId!], + ); + check(new Map(), []); +}); + +it("keeps projection identity on readiness/network changes and releases the final source reader", () => { + using screen = setup(); + screen.render(0, false); + expect(sources.lifecycle).toEqual({ acquired: 0, released: 0 }); + screen.render(); + screen.update(new Map(entries())); + expect(sources.lifecycle.acquired - sources.lifecycle.released).toBe(3); + const environments = screen.environments.get(0)!.environments; + const targets = screen.filters.get(0)!.availableTargets; + act(() => { + screen.registry.set(sources.catalog, { isReady: true, entries: new Map() }); + screen.registry.set(sources.network, "offline"); + }); + for (const id of [0, 1, 2]) { + expect(screen.environments.get(id)).toMatchObject({ isReady: true, networkStatus: "offline" }); + expect(screen.environments.get(id)!.environments).toBe(environments); + expect(screen.filters.get(id)!.availableTargets).toBe(targets); + } + screen.render(1); + expect(sources.lifecycle.acquired - sources.lifecycle.released).toBe(3); + screen.render(0, false); + expect(sources.lifecycle.acquired).toBe(sources.lifecycle.released); + const acquired = sources.lifecycle.acquired; + screen.render(); + expect(sources.lifecycle.acquired).toBe(acquired + 3); + screen.update(new Map(entries())); + expect(screen.filters.get(0)!.availableTargets).toHaveLength(6); + screen.render(0, false); + expect(sources.lifecycle.acquired).toBe(sources.lifecycle.released); +}); diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..7cba672f4af3a 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -5,7 +5,7 @@ import { type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations } from "./presentation"; @@ -31,18 +31,17 @@ export function projectEnvironmentPresentation( }; } +const environmentsAtom = Atom.make((get) => + [...get(environmentPresentations.presentationsAtom).entries()].map( + ([environmentId, presentation]) => projectEnvironmentPresentation(environmentId, presentation), + ), +).pipe(Atom.withLabel("mobile-environments")); + export function useEnvironments() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); const presentationById = useAtomValue(environmentPresentations.presentationsAtom); - - const environments = useMemo( - () => - [...presentationById.entries()].map(([environmentId, presentation]) => - projectEnvironmentPresentation(environmentId, presentation), - ), - [presentationById], - ); + const environments = useAtomValue(environmentsAtom); return { isReady: catalog.isReady, diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index c341dec4e2b9a..7abcf664f89e8 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -21,6 +21,7 @@ const reactTests = [ "src/native/useRestingTopOffset.test.tsx", "src/native/useRetryingImageSource.test.tsx", "src/state/queries.debounce.test.tsx", + "src/state/environments.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", ];