diff --git a/apps/web/src/components/GitActionsControl.lifecycle.test.ts b/apps/web/src/components/GitActionsControl.lifecycle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..84d5b1b4271f9358e2f365f129f382853821d161 --- /dev/null +++ b/apps/web/src/components/GitActionsControl.lifecycle.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +import { AuthSourceControlWriteScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + actions: [] as { + scope: { environmentId: string | null; cwd: string | null }; + input: { threadId?: string; action: string }; + }[], + toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[], + scopes: new Set(), + primaryScopes: new Set(), +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null), +})); +vi.mock("~/state/entities", () => ({ + useThreadProjection: () => null, + useThreadShell: (ref: { threadId: string }) => ({ branch: "main", id: ref.threadId }), +})); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: (environmentId: unknown, scope: string) => + (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope), + readEnvironmentScope: (environmentId: unknown, scope: string) => + (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); +vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: { discovery: () => null } })); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { status: () => null }, + vcsActionManager: { stateAtom: () => "vcs-state" }, +})); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { updateMetadata: async () => AsyncResult.success(undefined) }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ + data: { + sourceControlProviders: [], + isRepo: true, + refName: "main", + isDefaultRef: false, + hasPrimaryRemote: true, + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "file.ts", status: "modified", insertions: 1, deletions: 0 }], + }, + }, + error: null, + }), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => () => {} })); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: ( + select: (store: { + getDraftSession: () => null; + getDraftThreadByRef: () => null; + setDraftThreadContext: () => void; + }) => unknown, + ) => + select({ + getDraftSession: () => null, + getDraftThreadByRef: () => null, + setDraftThreadContext: () => {}, + }), +})); +vi.mock("~/lib/sourceControlActions", () => ({ + useSourceControlActionRunning: () => false, + useVcsInitAction: () => ({}), + useVcsPullAction: () => ({}), + useSourceControlPublishRepositoryAction: () => ({}), + useGitStackedAction: (scope: { environmentId: string | null; cwd: string | null }) => ({ + run: async (input: { featureBranch?: boolean; threadId?: string; action: string }) => { + state.actions.push({ scope, input }); + return { + _tag: "Success", + value: { + branch: { status: "skipped_not_requested" }, + toast: { + title: "Committed", + description: "Committed", + cta: { + kind: "run_action", + label: "Push & create PR", + action: { kind: "commit_push_pr" }, + }, + }, + }, + }; + }, + }), +})); +vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" })); +vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} })); +vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} })); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: (input: unknown) => input, + toastManager: { + add: (input: { + type?: string; + description?: string; + actionProps?: { onClick: () => void }; + }) => { + state.toasts.push(input); + return "toast"; + }, + update: () => {}, + close: () => {}, + }, +})); +vi.mock("~/components/ui/dialog", () => ({ + Dialog: "Dialog", + DialogDescription: "DialogDescription", + DialogFooter: "DialogFooter", + DialogHeader: "DialogHeader", + DialogPanel: "DialogPanel", + DialogPopup: "DialogPopup", + DialogTitle: "DialogTitle", +})); +vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" })); +vi.mock("~/components/ui/menu", () => ({ + Menu: "Menu", + MenuItem: "MenuItem", + MenuItemLabel: "MenuItemLabel", + MenuPopup: "MenuPopup", + MenuTrigger: "MenuTrigger", +})); +vi.mock("~/components/ui/popover", () => ({ + Popover: "Popover", + PopoverPopup: "PopoverPopup", + PopoverTrigger: "PopoverTrigger", +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: "Tooltip", + TooltipPopup: "TooltipPopup", + TooltipTrigger: "TooltipTrigger", +})); +vi.mock("~/components/ui/button", () => ({ Button: "button" })); +vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" })); +vi.mock("~/components/ui/input", () => ({ Input: "Input" })); +vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" })); +vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" })); +vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" })); +vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" })); +vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" })); +vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" })); + +import GitActionsControl from "./GitActionsControl"; + +import { createElement, memo } from "react"; +import { act, create } from "react-test-renderer"; + +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +const renderers: ReturnType[] = []; +// ThreadDetailsPanel keeps this component mounted behind React.memo when threads change. +const MemoGitActionsControl = memo(GitActionsControl); +const props = (thread: string, cwd: string) => ({ + gitCwd: cwd, + activeThreadRef: { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make(thread), + }, +}); + +async function renderActions(thread: string, cwd: string) { + let renderer!: ReturnType; + await act(async () => { + renderer = create(createElement(MemoGitActionsControl, props(thread, cwd))); + }); + renderers.push(renderer); + return renderer; +} + +async function clickQuickAction(renderer: ReturnType) { + const button = renderer.root + .findAllByType("button") + .find((button) => button.props.onClick && !button.props.disabled); + expect(button).toBeDefined(); + await act(async () => { + button!.props.onClick({}); + }); +} + +beforeEach(() => { + state.scopes = new Set([AuthSourceControlWriteScope]); + state.primaryScopes = new Set([AuthSourceControlWriteScope]); + state.actions = []; + state.toasts = []; +}); + +afterEach(async () => { + await act(async () => { + for (const renderer of renderers.splice(0)) renderer.unmount(); + }); +}); + +describe("Git action targets across thread navigation", () => { + it("runs a retained PR toast in the thread that produced it", async () => { + const renderer = await renderActions("first", "/first"); + await clickQuickAction(renderer); + const toast = state.toasts.find((toast) => toast.actionProps)!; + expect(toast).toBeDefined(); + await act(async () => { + renderer.update(createElement(MemoGitActionsControl, props("second", "/second"))); + }); + await act(async () => { + toast.actionProps!.onClick(); + }); + expect(state.actions.at(-1)).toMatchObject({ + scope: { cwd: "/first" }, + input: { threadId: "first", action: "commit_push_pr" }, + }); + }); + + it("runs the memoized panel button in the current thread after navigation", async () => { + const renderer = await renderActions("first", "/first"); + for (const [thread, cwd] of [ + ["first", "/first"], + ["second", "/second"], + ["first", "/first"], + ] as const) { + await act(async () => { + renderer.update(createElement(MemoGitActionsControl, props(thread, cwd))); + }); + await clickQuickAction(renderer); + expect(state.actions.at(-1)).toMatchObject({ scope: { cwd }, input: { threadId: thread } }); + } + }); +}); diff --git a/apps/web/src/components/GitActionsControl.test.ts b/apps/web/src/components/GitActionsControl.test.ts index d4276a4f29d9cf7a2ed0d9054067fa2e57d84cb6..18651179654032797a95d5795e22d1bb834a4c48 100644 --- a/apps/web/src/components/GitActionsControl.test.ts +++ b/apps/web/src/components/GitActionsControl.test.ts @@ -23,15 +23,14 @@ const state = vi.hoisted(() => ({ vi.mock("react", async (importOriginal) => ({ ...(await importOriginal()), - useCallback: (callback: unknown) => callback, + useCallback: (callback: typeof state.run) => { + if (callback?.constructor.name === "AsyncFunction") state.run = callback; + return callback; + }, useMemo: (factory: () => unknown) => factory(), useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], useRef: (current: unknown) => ({ current }), useEffect: () => {}, - useEffectEvent: (callback: typeof state.run) => { - state.run = callback; - return callback; - }, })); vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6419ce2a779c39289bb54bb206e93ddc3e465293..8164537ee3a248cf204b53a038286741d4685e8e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -21,15 +21,7 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; -import { - type MouseEvent, - useCallback, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { CheckIcon, @@ -1062,7 +1054,6 @@ export default function GitActionsControl({ ); const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope)); const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null; - let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; useEffect(() => { if (!inlineSuccess) return; @@ -1258,8 +1249,10 @@ export default function GitActionsControl({ }; }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); - runGitActionWithToast = useEffectEvent( - async ({ + // React 19.2 leaves useEffectEvent stale inside the memoized details panel. + // Bind each render's actions to its checkout and thread, including retained toast actions. + const runGitActionWithToast = useCallback( + async function runGitActionWithToast({ action, commitMessage, onConfirmed, @@ -1267,7 +1260,7 @@ export default function GitActionsControl({ statusOverride, featureBranch = false, filePaths, - }: RunGitActionWithToastInput) => { + }: RunGitActionWithToastInput) { if ( activeEnvironmentId === null || !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || @@ -1415,6 +1408,20 @@ export default function GitActionsControl({ }); } }, + [ + activeDraftThread, + activeEnvironmentId, + activeServerThread, + gitStatusForActions, + isDefaultRef, + isLocalDraftThread, + isPanel, + openPrLink, + runImmediateGitAction, + successScopeKey, + syncThreadBranchAfterGitAction, + threadToastData, + ], ); const continuePendingDefaultBranchAction = () => {