Avoid rerendering toast action consumers

This commit is contained in:
Dotta committed 2026-04-14 11:54:44 -05:00
1 parent 8020f551ca
commit 42544053c4
20 files changed
+146 -45

No files matched your search

@@ -3,7 +3,7 @@ import type { ExecutionWorkspace } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Loader2 } from "lucide-react";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime, issueUrl } from "../lib/utils";
import { Button } from "./ui/button";
@@ -44,7 +44,7 @@ export function ExecutionWorkspaceCloseDialog({
onClosed,
}: ExecutionWorkspaceCloseDialogProps) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace";
const readinessQuery = useQuery({
+1 -1
View File
@@ -77,7 +77,7 @@ vi.mock("../context/CompanyContext", () => ({
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => toastState,
useToastActions: () => toastState,
}));
vi.mock("../api/issues", () => ({
+2 -2
View File
@@ -14,7 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
@@ -280,7 +280,7 @@ export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("todo");
+8 -2
View File
@@ -1,7 +1,12 @@
import { useEffect, useState } from "react";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext";
import {
useToastActions,
useToastState,
type ToastItem,
type ToastTone,
} from "../context/ToastContext";
import { cn } from "../lib/utils";
const toneClasses: Record<ToastTone, string> = {
@@ -75,7 +80,8 @@ function AnimatedToast({
}
export function ToastViewport() {
const { toasts, dismissToast } = useToast();
const toasts = useToastState();
const { dismissToast } = useToastActions();
if (toasts.length === 0) return null;
+3 -2
View File
@@ -6,7 +6,8 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { authApi } from "../api/auth";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToast } from "./ToastContext";
import { useToastActions } from "./ToastContext";
import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments";
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
@@ -722,7 +723,7 @@ export const __liveUpdatesTestUtils = {
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const location = useLocation();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const pathnameRef = useRef(location.pathname);
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToastProvider, useToastActions, useToastState } from "./ToastContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("ToastContext", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("does not rerender action-only consumers when toast state changes", () => {
const root = createRoot(container);
let actionOnlyRenderCount = 0;
let pushToastRef: ((input: { title: string }) => string | null) | null = null;
let clearToastsRef: (() => void) | null = null;
function ActionOnlyConsumer() {
actionOnlyRenderCount += 1;
const { pushToast, clearToasts } = useToastActions();
pushToastRef = pushToast;
clearToastsRef = clearToasts;
return null;
}
function ToastCount() {
const toasts = useToastState();
return <div data-testid="toast-count">{String(toasts.length)}</div>;
}
act(() => {
root.render(
<ToastProvider>
<ActionOnlyConsumer />
<ToastCount />
</ToastProvider>,
);
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0");
act(() => {
pushToastRef?.({ title: "Saved" });
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("1");
act(() => {
clearToastsRef?.();
});
expect(actionOnlyRenderCount).toBe(1);
expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0");
act(() => {
root.unmount();
});
});
});
+31 -10
View File
@@ -36,13 +36,16 @@ export interface ToastItem {
createdAt: number;
}
interface ToastContextValue {
toasts: ToastItem[];
interface ToastActionsContextValue {
pushToast: (input: ToastInput) => string | null;
dismissToast: (id: string) => void;
clearToasts: () => void;
}
interface ToastContextValue extends ToastActionsContextValue {
toasts: ToastItem[];
}
const DEFAULT_TTL_BY_TONE: Record<ToastTone, number> = {
info: 4000,
success: 3500,
@@ -55,7 +58,8 @@ const MAX_TOASTS = 5;
const DEDUPE_WINDOW_MS = 3500;
const DEDUPE_MAX_AGE_MS = 20000;
const ToastContext = createContext<ToastContextValue | null>(null);
const ToastStateContext = createContext<ToastItem[] | null>(null);
const ToastActionsContext = createContext<ToastActionsContextValue | null>(null);
function normalizeTtl(value: number | undefined, tone: ToastTone) {
const fallback = DEFAULT_TTL_BY_TONE[tone];
@@ -150,23 +154,40 @@ export function ToastProvider({ children }: { children: ReactNode }) {
timersRef.current.clear();
}, []);
const value = useMemo<ToastContextValue>(
const actions = useMemo<ToastActionsContextValue>(
() => ({
toasts,
pushToast,
dismissToast,
clearToasts,
}),
[toasts, pushToast, dismissToast, clearToasts],
[pushToast, dismissToast, clearToasts],
);
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
return (
<ToastActionsContext.Provider value={actions}>
<ToastStateContext.Provider value={toasts}>{children}</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
export function useToastState() {
const context = useContext(ToastStateContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
throw new Error("useToastState must be used within a ToastProvider");
}
return context;
}
export function useToastActions() {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error("useToastActions must be used within a ToastProvider");
}
return context;
}
export function useToast() {
const toasts = useToastState();
const actions = useToastActions();
return useMemo<ToastContextValue>(() => ({ toasts, ...actions }), [toasts, actions]);
}
+2 -2
View File
@@ -27,7 +27,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { useToastActions } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
@@ -255,7 +255,7 @@ export function AdapterManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");
+2 -2
View File
@@ -18,7 +18,7 @@ import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@@ -1540,7 +1540,7 @@ function ConfigurationTab({
hideInstructionsFile?: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const lastAgentRef = useRef(agent);
+2 -2
View File
@@ -11,7 +11,7 @@ import type {
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
@@ -580,7 +580,7 @@ function expandAncestors(filePath: string): string[] {
export function CompanyExport() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const navigate = useNavigate();
const location = useLocation();
const { data: session, isFetched: isSessionFetched } = useQuery({
+2 -2
View File
@@ -9,7 +9,7 @@ import type {
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
@@ -651,7 +651,7 @@ export function CompanyImport() {
setSelectedCompanyId,
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const packageInputRef = useRef<HTMLInputElement | null>(null);
const { data: session } = useQuery({
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
@@ -34,7 +34,7 @@ export function CompanySettings() {
setSelectedCompanyId
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
// General settings local state
const [companyName, setCompanyName] = useState("");
+3 -3
View File
@@ -14,7 +14,7 @@ import type {
import { companySkillsApi } from "../api/companySkills";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -530,7 +530,7 @@ function SkillPane({
onSave: () => void;
savePending: boolean;
}) {
const { pushToast } = useToast();
const { pushToast } = useToastActions();
if (!detail) {
if (loading) {
@@ -759,7 +759,7 @@ export function CompanySkills() {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [createOpen, setCreateOpen] = useState(false);
+3 -2
View File
@@ -13,7 +13,7 @@ import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
@@ -373,7 +373,8 @@ export function IssueDetail() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const { isMobile } = useSidebar();
const [moreOpen, setMoreOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
+2 -2
View File
@@ -27,7 +27,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { useToastActions } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function firstNonEmptyLine(value: string | null | undefined): string | null {
@@ -64,7 +64,7 @@ export function PluginManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const [installPackage, setInstallPackage] = useState("");
const [installDialogOpen, setInstallDialogOpen] = useState(false);
+2 -2
View File
@@ -12,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
@@ -455,7 +455,7 @@ export function ProjectDetail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
+2 -2
View File
@@ -22,7 +22,7 @@ import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
import { timeAgo } from "../lib/timeAgo";
@@ -268,7 +268,7 @@ export function RoutineDetail() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const hydratedRoutineIdRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
+1 -1
View File
@@ -31,7 +31,7 @@ vi.mock("../context/BreadcrumbContext", () => ({
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
useToastActions: () => ({ pushToast: vi.fn() }),
}));
vi.mock("../api/routines", () => ({
+2 -2
View File
@@ -9,7 +9,7 @@ import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
@@ -293,7 +293,7 @@ export function Routines() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { pushToast } = useToast();
const { pushToast } = useToastActions();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
+2 -2
View File
@@ -34,7 +34,7 @@ import type {
} from "@paperclipai/shared";
import { pluginsApi } from "@/api/plugins";
import { ApiError } from "@/api/client";
import { useToast, type ToastInput } from "@/context/ToastContext";
import { useToastActions, type ToastInput } from "@/context/ToastContext";
// ---------------------------------------------------------------------------
// Bridge error type (mirrors the SDK's PluginBridgeError)
@@ -369,7 +369,7 @@ export function useHostContext(): PluginHostContext {
// ---------------------------------------------------------------------------
export function usePluginToast(): PluginToastFn {
const { pushToast } = useToast();
const { pushToast } = useToastActions();
return useCallback(
(input: PluginToastInput) => pushToast(input),
[pushToast],