diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 472c1a266c6cd..bdb05cb1d29f4 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -37,6 +37,14 @@ const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({ serverConfigAtom: serverEnvironment.configValueAtom, }); +export const environmentConnectedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(remoteEnvironmentProjections.runtimeStateAtom(environmentId))?.connectionState === + "connected", + ).pipe(Atom.withLabel(`mobile:environment-connected:${environmentId}`)), +); + const EMPTY_SAVED_CONNECTION_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile:saved-connection:empty"), ); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index 8b662c1257d9a..ce3f8a83f2c13 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -25,9 +25,8 @@ const state = vi.hoisted(() => ({ vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), - useEffect: () => {}, - useEffectEvent: (callback: unknown) => callback, })); +vi.mock("@effect/atom-react", () => ({ useAtomMount: () => {} })); vi.mock("./session", () => ({ useEnvironmentScope: (environmentId: unknown, scope: string) => (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), @@ -51,7 +50,10 @@ vi.mock("./queries", () => ({ })); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); -vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); +vi.mock("./use-remote-environment-registry", () => ({ + environmentConnectedAtom: () => null, + setPendingConnectionError: () => {}, +})); vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: (result: { type: string; description?: string }) => state.results.push(result), diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx deleted file mode 100644 index cfd1dd4052fec..0000000000000 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { act, useEffect, useReducer } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { AsyncResult } from "effect/reactivity"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const harness = vi.hoisted(() => ({ - selection: { - selectedThread: { environmentId: "environment-1", id: "thread-1" } as unknown, - selectedThreadProject: { workspaceRoot: "/repo" } as unknown, - selectedEnvironmentRuntime: { connectionState: "connected" } as unknown, - }, - worktree: { selectedThreadCwd: "/repo" as string | null, selectedThreadWorktreePath: null }, - refreshStatusCalls: [] as Array, - renderCount: 0, - onRefreshSettled: () => {}, -})); - -vi.mock("./use-atom-command", () => ({ - useAtomCommand: (command: unknown) => - command === "refreshStatus" - ? async (input: unknown) => { - harness.refreshStatusCalls.push(input); - harness.onRefreshSettled(); - return AsyncResult.success({}); - } - : async () => AsyncResult.success({}), -})); -vi.mock("./vcs", () => ({ - vcsEnvironment: { refreshStatus: "refreshStatus" }, - vcsActionManager: { runStackedAction: () => "runStackedAction", track: vi.fn() }, -})); -vi.mock("./threads", () => ({ threadEnvironment: { updateMetadata: "updateMetadata" } })); -vi.mock("./queries", () => ({ useBranches: () => ({ data: null, refresh: () => {} }) })); -vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); -vi.mock("./session", () => ({ - readEnvironmentScope: () => true, - useEnvironmentScope: () => true, -})); -vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); -vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} })); -vi.mock("./use-thread-selection", () => ({ - useThreadSelection: () => { - harness.renderCount += 1; - if (harness.renderCount > 50) { - throw new Error("Consumer rerendered more than 50 times"); - } - return harness.selection; - }, -})); -vi.mock("./use-selected-thread-worktree", () => ({ - useSelectedThreadWorktree: () => harness.worktree, -})); - -import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; - -let root: Root | null = null; -let rerender: () => void = () => {}; - -function Consumer() { - const [, bump] = useReducer((count: number) => count + 1, 0); - useEffect(() => { - rerender = bump; - }); - useSelectedThreadGitActions(); - return null; -} - -async function flush() { - for (let index = 0; index < 5; index += 1) { - await act(async () => { - await Promise.resolve(); - }); - } -} - -beforeEach(() => { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - harness.selection = { - selectedThread: { environmentId: "environment-1", id: "thread-1" }, - selectedThreadProject: { workspaceRoot: "/repo" }, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - harness.worktree = { selectedThreadCwd: "/repo", selectedThreadWorktreePath: null }; - harness.refreshStatusCalls = []; - harness.renderCount = 0; - harness.onRefreshSettled = () => rerender(); -}); - -afterEach(() => { - act(() => root?.unmount()); - root = null; -}); - -describe("useSelectedThreadGitActions", () => { - it("refreshes once on mount and does not loop when refreshes rerender the consumer", async () => { - root = createRoot(document.createElement("div")); - act(() => root!.render()); - await flush(); - - harness.selection = { - selectedThread: { environmentId: "environment-1", id: "thread-1" }, - selectedThreadProject: { workspaceRoot: "/repo" }, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - act(() => rerender()); - await flush(); - - expect(harness.refreshStatusCalls).toEqual([ - { environmentId: "environment-1", input: { cwd: "/repo" } }, - ]); - }); -}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 7cbb0a5e24a32..a5a35c47f2c4d 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useEffectEvent, useMemo } from "react"; +import { useAtomMount } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + createVcsStatusRevalidationAtomFamily, type GitActionRequestInput, type VcsActionOperation, type VcsRef, @@ -26,12 +28,31 @@ import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { readEnvironmentScope, useEnvironmentScope } from "./session"; -import { setPendingConnectionError } from "./use-remote-environment-registry"; +import { + environmentConnectedAtom, + setPendingConnectionError, +} from "./use-remote-environment-registry"; import { useAtomCommand } from "./use-atom-command"; import { showGitActionResult } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; +function refreshFailureMessage(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : "Failed to refresh git status."; +} + +// Selecting a thread or reconnecting its environment refreshes the checkout's +// status once, shared by every sheet that shows it. +const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({ + refreshStatus: vcsEnvironment.refreshStatus, + connected: environmentConnectedAtom, + onRefreshed: (result) => + setPendingConnectionError( + AsyncResult.isFailure(result) ? refreshFailureMessage(result.cause) : null, + ), +}); + export function useSelectedThreadGitActions() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -41,8 +62,7 @@ export function useSelectedThreadGitActions() { const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); - const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } = - useThreadSelection(); + const { selectedThread, selectedThreadProject } = useThreadSelection(); const canWriteSourceControl = useEnvironmentScope( selectedThread?.environmentId ?? null, AuthSourceControlWriteScope, @@ -130,9 +150,7 @@ export function useSelectedThreadGitActions() { execute, ); if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - const message = error instanceof Error ? error.message : "Failed to refresh git status."; - setPendingConnectionError(message); + setPendingConnectionError(refreshFailureMessage(result.cause)); return null; } setPendingConnectionError(null); @@ -141,32 +159,13 @@ export function useSelectedThreadGitActions() { [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], ); - // Key refreshes on primitives; streaming frequently replaces the selected thread object. - const selectedEnvironmentId = selectedThread?.environmentId ?? null; - const selectedThreadId = selectedThread?.id ?? null; - const hasSelectedThreadProject = selectedThreadProject !== null; - const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected"; - const refreshOnSelection = useEffectEvent(() => { - void refreshSelectedThreadGitStatus({ quiet: true }); - }); - useEffect(() => { - if ( - selectedEnvironmentId === null || - selectedThreadId === null || - !hasSelectedThreadProject || - selectedThreadCwd === null || - !isEnvironmentConnected - ) { - return; - } - refreshOnSelection(); - }, [ - selectedEnvironmentId, - selectedThreadId, - hasSelectedThreadProject, - selectedThreadCwd, - isEnvironmentConnected, - ]); + useAtomMount( + selectedThreadStatusRevalidation( + selectedThread && selectedThreadProject && selectedThreadCwd + ? { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd } + : null, + ), + ); const runSelectedThreadGitMutation = useCallback( async ( diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 0aa2490db3258..e05ec4a2c8eb6 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -360,3 +360,4 @@ export function createVcsEnvironmentAtoms( export * from "./gitActions.ts"; export * from "./vcsAction.ts"; export * from "./vcsRef.ts"; +export * from "./vcsStatusRevalidation.ts"; diff --git a/packages/client-runtime/src/state/vcsStatusRevalidation.test.ts b/packages/client-runtime/src/state/vcsStatusRevalidation.test.ts new file mode 100644 index 0000000000000..9a1d8c3dd2717 --- /dev/null +++ b/packages/client-runtime/src/state/vcsStatusRevalidation.test.ts @@ -0,0 +1,93 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createVcsStatusRevalidationAtomFamily } from "./vcsStatusRevalidation.ts"; + +const environmentId = EnvironmentId.make("environment-1"); + +function setup(options: { readonly connected?: boolean; readonly wake?: boolean }) { + const connectedAtom = Atom.make(options.connected ?? false); + const wakes = new Set<() => void>(); + const run = vi.fn(async () => AsyncResult.success(null)); + const family = createVcsStatusRevalidationAtomFamily({ + refreshStatus: { label: "refresh-status", run }, + ...(options.connected === undefined ? {} : { connected: () => connectedAtom }), + ...(options.wake + ? { + subscribeWake: (wake: () => void) => { + wakes.add(wake); + return () => wakes.delete(wake); + }, + wakeDebounceMs: 250, + } + : {}), + }); + const registry = AtomRegistry.make(); + const wake = () => { + for (const listener of wakes) listener(); + }; + return { registry, family, connectedAtom, run, wakes, wake }; +} + +describe("createVcsStatusRevalidationAtomFamily", () => { + it("refreshes once per connection, shared by every subscriber, and again on reconnect", () => { + const { registry, family, connectedAtom, run } = setup({ connected: true }); + const target = { environmentId, cwd: "/repo" }; + const first = registry.mount(family(target)); + const second = registry.mount(family({ environmentId, cwd: "/repo" })); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(expect.anything(), { environmentId, input: { cwd: "/repo" } }); + + registry.set(connectedAtom, false); + expect(run).toHaveBeenCalledTimes(1); + registry.set(connectedAtom, true); + expect(run).toHaveBeenCalledTimes(2); + + registry.mount(family({ environmentId, cwd: "/other" }))(); + expect(run).toHaveBeenCalledTimes(3); + first(); + second(); + }); + + it("waits for the connection before the first refresh", () => { + const { registry, family, connectedAtom, run } = setup({ connected: false }); + const unmount = registry.mount(family({ environmentId, cwd: "/repo" })); + expect(run).not.toHaveBeenCalled(); + registry.set(connectedAtom, true); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("debounces wake signals and stops listening with the last subscriber", () => { + vi.useFakeTimers(); + try { + const { registry, family, run, wakes, wake } = setup({ wake: true }); + const unmount = registry.mount(family({ environmentId, cwd: "/repo" })); + expect(run).not.toHaveBeenCalled(); + + wake(); + vi.advanceTimersByTime(100); + wake(); + vi.advanceTimersByTime(249); + expect(run).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(run).toHaveBeenCalledTimes(1); + + wake(); + unmount(); + vi.runAllTimers(); + expect(run).toHaveBeenCalledTimes(1); + expect(wakes.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("does nothing for an idle target", () => { + const { registry, family, run, wakes } = setup({ connected: true, wake: true }); + registry.mount(family(null))(); + expect(run).not.toHaveBeenCalled(); + expect(wakes.size).toBe(0); + }); +}); diff --git a/packages/client-runtime/src/state/vcsStatusRevalidation.ts b/packages/client-runtime/src/state/vcsStatusRevalidation.ts new file mode 100644 index 0000000000000..b248d280c0d70 --- /dev/null +++ b/packages/client-runtime/src/state/vcsStatusRevalidation.ts @@ -0,0 +1,73 @@ +// @effect-diagnostics globalTimers:off - The wake debounce runs in an atom subscription, outside an Effect runtime. +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom, type AtomRegistry } from "effect/reactivity"; + +import { type AtomCommand, type AtomCommandResult, runAtomCommand } from "./runtime.ts"; + +interface VcsStatusRevalidationTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; +} + +/** + * One atom per environment and cwd that refreshes VCS status while mounted and + * shared by every subscriber. Refreshes only when `connected` is given and true + * (on mount and on each reconnect), and on `subscribeWake` (debounced); the wake + * subscription and timer end with the last subscriber. + */ +export function createVcsStatusRevalidationAtomFamily(input: { + readonly refreshStatus: AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, + R, + unknown + >; + readonly connected?: (environmentId: EnvironmentId) => Atom.Atom; + readonly subscribeWake?: (wake: () => void) => () => void; + readonly wakeDebounceMs?: number; + readonly onRefreshed?: (result: AtomCommandResult) => void; +}) { + const family = Atom.family((target: VcsStatusRevalidationTarget) => { + const { environmentId, cwd } = target; + const key = `${environmentId}:${cwd}`; + + const refresh = async (registry: AtomRegistry.AtomRegistry) => { + const result = await runAtomCommand( + registry, + input.refreshStatus, + { environmentId, input: { cwd } }, + { reportFailure: false }, + ); + input.onRefreshed?.(result); + }; + + const wakeAtom = Atom.make((get) => { + const subscribeWake = input.subscribeWake; + if (subscribeWake === undefined) return; + const registry = get.registry; + let timer: ReturnType | undefined; + const unsubscribe = subscribeWake(() => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + void refresh(registry); + }, input.wakeDebounceMs ?? 0); + }); + get.addFinalizer(() => { + if (timer !== undefined) clearTimeout(timer); + unsubscribe(); + }); + }).pipe(Atom.withLabel(`vcs-status-revalidation:wake:${key}`)); + + return Atom.make((get) => { + get(wakeAtom); + const connected = input.connected; + // Rebuilds only when the connection flag flips, so each reconnect refreshes once. + if (connected !== undefined && get(connected(environmentId))) { + void refresh(get.registry); + } + }).pipe(Atom.withLabel(`vcs-status-revalidation:${key}`)); + }); + const idle = Atom.make(undefined).pipe(Atom.withLabel("vcs-status-revalidation:idle")); + return (target: VcsStatusRevalidationTarget | null): Atom.Atom => + target === null ? idle : family({ environmentId: target.environmentId, cwd: target.cwd }); +}