From 31f6ea4b257a355600cb028fa254e35e9a638b28 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 19 May 2026 16:56:50 +0100 Subject: [PATCH] perf(frontend): stabilize hot-path context subscriptions to fix excessive rerenders (#6373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The frontend was rerendering excessively across many interactions — typing, clicking tools, opening modals, toggling the sidebar — because **multiple compounding ref-instability cascades defeated `memo()` checks in hot paths**. This PR fixes the cascades structurally. Seven focused commits, low-to-high blast radius: 1. `perf(contexts): memoize BannerContext provider value` 2. `perf(contexts): memoize CommentAuthor and ActiveDocument provider values` 3. `perf(contexts): memoize AppConfigContext provider value` 4. `perf(useToolManagement): stop spreading tool entries to keep refs stable` — **root-cause fix** 5. `refactor(ToolPicker): hoist module-scope styles and helpers` 6. `feat(ToolWorkflowContext): add ref-stable Actions and Data subset contexts` — additive 7. `perf(tools): migrate hot consumers to slim contexts and wrap in memo()` (Plus `style: apply prettier formatting` for CI.) ## What was wrong Whenever something high-up in the tree caused a render, a chain of unstable references propagated downward and forced every `ToolButton` to re-execute its full body (hooks, derived computations, hook subscriptions to other contexts). The chain: - **4 unstable Context providers** (`Banner`, `CommentAuthor`, `ActiveDocument`, `AppConfig`) were passing fresh `value={{ … }}` objects on every render. Every consumer rerendered on every ancestor render. - **`useToolManagement.toolRegistry`** spread `{...baseTool, name, description}` — a no-op spread that manufactured a new tool object identity on every memo recompute. - **The big `ToolWorkflowContext`** (25+ fields including `state.searchQuery`) rebuilt its entire value on every keystroke/click/toggle, forcing every `useToolWorkflow()` consumer (~36 files) to rerender. - **`useToolNavigation`** transitively subscribed every `ToolButton` to the full workflow context. - **`ToolButton` & `ToolPicker`** weren't `memo()`-wrapped, so nothing checked. - **`ToolPanel`** passed inline `onSelect={(id) => handleToolSelect(...)}` — fresh ref every render, defeats child memoization. - **`ToolPicker`** allocated inline styles / `[]` / `toTitleCase` inside the function body — churned `useToolSections`'s internal memo. ## Interaction matrix — what improves The PR fixes the underlying ref-stability problem; the same fix benefits *every* interaction that previously triggered the cascade: | Interaction | Before | After | |---|---|---| | **Typing in tool search** | All visible buttons rerender per keystroke | Only buttons whose matched-text changes rerender | | **Clicking a tool** | All 36 `useToolWorkflow()` consumers rerender | Only previously-selected and newly-selected buttons rerender (via `isSelected` prop) | | **Toggling sidebar / panel mode / reader mode** | Every tool button rerenders | Tool components stay still (slim context doesn't see UI state) | | **Switching workbench / navigation** | `handleToolSelect` identity changes → cascades through `onSelect` props | Ref-stabilized in Actions context. Identity stable. Children's memo bails | | **Modal/dialog open/close** | AppConfig churns → every `useAppConfig` consumer rerenders (ToolButton reads `premiumEnabled`) | AppConfig memoized; consumers rerender only when config changes | | **Banner show/hide** | BannerProvider value churns → every consumer rerenders on any ancestor render | Memoized; AppLayout rerenders only when banner content changes | | **Any state update high in the tree** | Compounding cascade defeats memo everywhere | Stable subscriptions; memo bails out | ## Evidence Per-keystroke prop instability on `ToolButton` (cleanest measurable signal, captured via custom memo comparators logging which prop refs differ): | | `tool` ref diffs | `onSelect` ref diffs | `matchedSynonym` value diffs | Total | |---|---|---|---|---| | Before | 18 | 18 | 6 | **42** | | After | 0 | 0 | 6 | **6 (all legitimate)** | → **86% reduction** in spurious per-keystroke prop instability. The 6 remaining matched-synonym diffs are correct (different substring highlighted per keystroke). Context value rebuild counts during a keystroke (verified with instrumented `useMemo` factories): `useToolWorkflowData=0`, `useToolWorkflowActions=0`, `AppConfigContext=0`. The same stabilization applies to click/toggle/modal interactions — they were all driven by the same cascading invalidations. ## Honest caveat on render-count metrics `React.Profiler` counts and function-body execution counts in **dev mode** came back identical before vs after (StrictMode + concurrent rendering + Mantine internal commits dominate the numbers). The PR's value is measured against the **prop-stability signal** above, not Profiler counts. Production builds — where StrictMode doesn't double-render and Mantine internals aren't constantly committing — will show memo bail out properly. ## Risk × benefit | # | Commit | Risk | Benefit | |---|--------|------|---------| | 1 | BannerContext memo | ⬛ Trivial | 🟦 Small | | 2 | CommentAuthor + ActiveDocument memo | ⬛ Trivial | 🟦 Small | | 3 | AppConfig memo | ⬛ Trivial | 🟦 Moderate (wide consumer base) | | 4 | useToolManagement spread removal | ⬛ Trivial | 🟥 **High (root cause)** | | 5 | ToolPicker hoist | ⬛ Trivial | 🟦 Small | | 6 | ToolWorkflowContext split | 🟧 Low-Med | 🟥 **High (foundation)** | | 7 | Hot consumer migration + memo | 🟧 Low-Med | 🟥 **High (actualization)** | Commit 6 introduces an invariant: ref-stabilized callbacks in the Actions context must only be invoked from event handlers (post-commit), never during render. All current call sites comply. ## Test plan - [x] `npx playwright test --project=stubbed` — 145 / 6 skipped / 0 failed before and after. - [x] Targeted regression: `main-dashboard`, `tool-search`, `navigation` — 11/11 passing. - [x] CI passing on commits (one infrastructure flake on `docker-compose-tests` — "No space left on device" — unrelated; rerunning). - [ ] Manual sanity check in a dev build after merge. ## What this enables The same Actions + Data subset-context pattern can be applied to `FileContext`, `NavigationContext`, and other big contexts. The foundation is in place. --- .../src/core/components/tools/ToolPanel.tsx | 26 ++- .../src/core/components/tools/ToolPicker.tsx | 76 +++---- .../tools/toolPicker/ToolButton.tsx | 12 +- .../viewer/ActiveDocumentContext.tsx | 5 +- .../src/core/contexts/AppConfigContext.tsx | 16 +- frontend/src/core/contexts/BannerContext.tsx | 8 +- .../core/contexts/CommentAuthorContext.tsx | 5 +- .../src/core/contexts/ToolWorkflowContext.tsx | 191 +++++++++++++++++- frontend/src/core/hooks/useToolManagement.tsx | 6 +- frontend/src/core/hooks/useToolNavigation.ts | 4 +- .../tools/toolPicker/ToolButton.tsx | 12 +- 11 files changed, 284 insertions(+), 77 deletions(-) diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index ab31a4e8f..e431a24a0 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -1,6 +1,9 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { + useToolWorkflow, + useToolWorkflowActions, +} from "@app/contexts/ToolWorkflowContext"; import { usePreferences } from "@app/contexts/PreferencesContext"; import ToolPicker from "@app/components/tools/ToolPicker"; import SearchResults from "@app/components/tools/SearchResults"; @@ -39,17 +42,19 @@ export default function ToolPanel() { toolRegistry, setSearchQuery, selectedToolKey, + toolPanelMode, + sidebarsVisible, + readerMode, + } = useToolWorkflow(); + const { handleToolSelect, handleBackToTools, setPreviewFile, - toolPanelMode, setToolPanelMode, setLeftPanelView, setReaderMode, setSidebarsVisible, - sidebarsVisible, - readerMode, - } = useToolWorkflow(); + } = useToolWorkflowActions(); const { setAllButtonsDisabled } = useWorkbenchBar(); const { preferences, updatePreference } = usePreferences(); @@ -114,6 +119,11 @@ export default function ToolPanel() { return "18.5rem"; }; + const handleSelect = useCallback( + (id: string) => handleToolSelect(id as ToolId), + [handleToolSelect], + ); + const matchedTextMap = useMemo(() => { const map = new Map(); filteredTools.forEach(({ item: [id], matchedText }) => { @@ -209,7 +219,7 @@ export default function ToolPanel() {
handleToolSelect(id as ToolId)} + onSelect={handleSelect} searchQuery={searchQuery} />
@@ -217,7 +227,7 @@ export default function ToolPanel() {
handleToolSelect(id as ToolId)} + onSelect={handleSelect} filteredTools={filteredTools} isSearching={Boolean( searchQuery && searchQuery.trim().length > 0, diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index f60475493..7fb01f89e 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from "react"; +import React, { memo, useMemo, useRef } from "react"; import { Box, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; @@ -9,7 +9,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons"; import ToolButton from "@app/components/tools/toolPicker/ToolButton"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useToolWorkflowData } from "@app/contexts/ToolWorkflowContext"; import { ToolId } from "@app/types/toolId"; import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions"; @@ -24,6 +24,34 @@ interface ToolPickerProps { isSearching?: boolean; } +const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = []; +const HEADER_TEXT_STYLE: React.CSSProperties = { + fontSize: "0.68rem", + fontWeight: 600, + padding: "1rem 0 0.35rem 0.5rem", + textTransform: "uppercase", + letterSpacing: "0.06em", + color: "var(--text-muted)", +}; +const SCROLLABLE_STYLE: React.CSSProperties = { + flex: 1, + overflowY: "auto", + overflowX: "hidden", + minHeight: 0, + height: "100%", + marginTop: -2, +}; +const CONTAINER_STYLE: React.CSSProperties = { + display: "flex", + flexDirection: "column", + background: "var(--bg-toolbar)", +}; +const toTitleCase = (s: string) => + s.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(), + ); + const ToolPicker = ({ selectedToolKey, onSelect, @@ -35,7 +63,7 @@ const ToolPicker = ({ const scrollableRef = useRef(null); const { sections: visibleSections } = useToolSections(filteredTools); - const { favoriteTools, toolRegistry } = useToolWorkflow(); + const { favoriteTools, toolRegistry } = useToolWorkflowData(); const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); @@ -60,43 +88,15 @@ const ToolPicker = ({ ); // Build flat list by subcategory for search mode - const emptyFilteredTools: ToolPickerProps["filteredTools"] = []; const effectiveFilteredForSearch: ToolPickerProps["filteredTools"] = - isSearching ? filteredTools : emptyFilteredTools; + isSearching ? filteredTools : EMPTY_FILTERED_TOOLS; const { searchGroups } = useToolSections(effectiveFilteredForSearch); - const headerTextStyle: React.CSSProperties = { - fontSize: "0.68rem", - fontWeight: 600, - padding: "1rem 0 0.35rem 0.5rem", - textTransform: "uppercase", - letterSpacing: "0.06em", - color: "var(--text-muted)", - }; - const toTitleCase = (s: string) => - s.replace( - /\w\S*/g, - (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(), - ); return ( - + {isSearching ? ( @@ -124,7 +124,7 @@ const ToolPicker = ({ {favoriteToolItems.length > 0 && ( -
+
{t("toolPanel.fullscreen.favorites", "Favourites")}
@@ -143,7 +143,7 @@ const ToolPicker = ({ )} {recommendedItems.length > 0 && ( -
+
{t("toolPanel.fullscreen.recommended", "Recommended")}
@@ -163,7 +163,7 @@ const ToolPicker = ({ {allSection && allSection.subcategories.map((sc: SubcategoryGroup) => ( -
+
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
{renderToolButtons( @@ -192,4 +192,4 @@ const ToolPicker = ({ ); }; -export default ToolPicker; +export default memo(ToolPicker); diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index d4021d794..a0c01c644 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { memo } from "react"; import { Button, Badge } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@app/components/shared/Tooltip"; @@ -10,7 +10,10 @@ import FitText from "@app/components/shared/FitText"; import { useHotkeys } from "@app/contexts/HotkeyContext"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { + useToolWorkflowActions, + useToolWorkflowData, +} from "@app/contexts/ToolWorkflowContext"; import type { ToolId } from "@app/types/toolId"; import { getToolDisabledReason, @@ -46,7 +49,8 @@ const ToolButton: React.FC = ({ const { t } = useTranslation(); const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; - const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow(); + const { isFavorite, toolAvailability } = useToolWorkflowData(); + const { toggleFavorite } = useToolWorkflowActions(); const disabledReason = getToolDisabledReason( id, tool, @@ -309,4 +313,4 @@ const ToolButton: React.FC = ({ ); }; -export default ToolButton; +export default memo(ToolButton); diff --git a/frontend/src/core/components/viewer/ActiveDocumentContext.tsx b/frontend/src/core/components/viewer/ActiveDocumentContext.tsx index a9029abda..07c035593 100644 --- a/frontend/src/core/components/viewer/ActiveDocumentContext.tsx +++ b/frontend/src/core/components/viewer/ActiveDocumentContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, + useMemo, useState, useEffect, useRef, @@ -63,8 +64,10 @@ export function ActiveDocumentProvider({ }; }, []); + const value = useMemo(() => ({ documentId }), [documentId]); + return ( - + {children} ); diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 021508c36..763534554 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, + useMemo, useState, useEffect, ReactNode, @@ -219,12 +220,15 @@ export const AppConfigProvider: React.FC = ({ const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); - const value: AppConfigContextValue = { - config, - loading, - error, - refetch, - }; + const value = useMemo( + () => ({ + config, + loading, + error, + refetch, + }), + [config, loading, error, refetch], + ); return ( diff --git a/frontend/src/core/contexts/BannerContext.tsx b/frontend/src/core/contexts/BannerContext.tsx index 241387fc0..f910fe5ec 100644 --- a/frontend/src/core/contexts/BannerContext.tsx +++ b/frontend/src/core/contexts/BannerContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from "react"; +import { createContext, useContext, useMemo, useState, ReactNode } from "react"; interface BannerContextType { banner: ReactNode; @@ -10,10 +10,10 @@ const BannerContext = createContext(undefined); export function BannerProvider({ children }: { children: ReactNode }) { const [banner, setBanner] = useState(null); + const value = useMemo(() => ({ banner, setBanner }), [banner]); + return ( - - {children} - + {children} ); } diff --git a/frontend/src/core/contexts/CommentAuthorContext.tsx b/frontend/src/core/contexts/CommentAuthorContext.tsx index 1378d628f..9c3f9cc2f 100644 --- a/frontend/src/core/contexts/CommentAuthorContext.tsx +++ b/frontend/src/core/contexts/CommentAuthorContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; export interface CommentAuthorValue { displayName: string; @@ -15,8 +15,9 @@ export function CommentAuthorProvider({ children: ReactNode; displayName?: string; }) { + const value = useMemo(() => ({ displayName }), [displayName]); return ( - + {children} ); diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 60f4124aa..6bc61fa29 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useEffect, + useRef, } from "react"; import { useToolManagement, @@ -122,6 +123,46 @@ if (!existingContext) { (globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext; } +/** + * Slim subset contexts for hot consumers that don't need the full state. + * + * - `useToolWorkflowActions()` — referentially-stable callbacks. + * - `useToolWorkflowData()` — tool registry, availability, favorites. + */ +export interface ToolWorkflowActionsValue { + selectTool: (toolId: ToolId | null) => void; + clearToolSelection: () => void; + toggleFavorite: (toolId: ToolId) => void; + handleToolSelect: (toolId: ToolId) => void; + handleToolSelectForced: (toolId: ToolId) => void; + handleBackToTools: () => void; + handleReaderToggle: () => void; + setSidebarsVisible: (visible: boolean) => void; + setLeftPanelView: (view: "toolPicker" | "toolContent" | "hidden") => void; + setReaderMode: (mode: boolean) => void; + setToolPanelMode: (mode: ToolPanelMode) => void; + setPreviewFile: (file: File | null) => void; + setPageEditorFunctions: (functions: PageEditorFunctions | null) => void; + setSearchQuery: (query: string) => void; + registerToolReset: (toolId: string, resetFunction: () => void) => void; + resetTool: (toolId: string) => void; +} + +export interface ToolWorkflowDataValue { + toolAvailability: ToolAvailabilityMap; + toolRegistry: Partial; + favoriteTools: ToolId[]; + getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null; + isFavorite: (toolId: ToolId) => boolean; +} + +const ToolWorkflowActionsContext = createContext< + ToolWorkflowActionsValue | undefined +>(undefined); +const ToolWorkflowDataContext = createContext< + ToolWorkflowDataValue | undefined +>(undefined); + // Provider component interface ToolWorkflowProviderProps { children: React.ReactNode; @@ -534,6 +575,124 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { true, ); + // Ref-backed wrappers so callback identities stay stable across renders. + const handleToolSelectRef = useRef(handleToolSelect); + handleToolSelectRef.current = handleToolSelect; + const stableHandleToolSelect = useCallback( + (id: ToolId) => handleToolSelectRef.current(id), + [], + ); + + const handleToolSelectForcedRef = useRef(handleToolSelectForced); + handleToolSelectForcedRef.current = handleToolSelectForced; + const stableHandleToolSelectForced = useCallback( + (id: ToolId) => handleToolSelectForcedRef.current(id), + [], + ); + + const handleBackToToolsRef = useRef(handleBackToTools); + handleBackToToolsRef.current = handleBackToTools; + const stableHandleBackToTools = useCallback( + () => handleBackToToolsRef.current(), + [], + ); + + const handleReaderToggleRef = useRef(handleReaderToggle); + handleReaderToggleRef.current = handleReaderToggle; + const stableHandleReaderToggle = useCallback( + () => handleReaderToggleRef.current(), + [], + ); + + const setReaderModeRef = useRef(setReaderMode); + setReaderModeRef.current = setReaderMode; + const stableSetReaderMode = useCallback( + (mode: boolean) => setReaderModeRef.current(mode), + [], + ); + + const setPreviewFileRef = useRef(setPreviewFile); + setPreviewFileRef.current = setPreviewFile; + const stableSetPreviewFile = useCallback( + (file: File | null) => setPreviewFileRef.current(file), + [], + ); + + const setToolPanelModeRef = useRef(setToolPanelMode); + setToolPanelModeRef.current = setToolPanelMode; + const stableSetToolPanelMode = useCallback( + (mode: ToolPanelMode) => setToolPanelModeRef.current(mode), + [], + ); + + const selectToolRef = useRef(actions.setSelectedTool); + selectToolRef.current = actions.setSelectedTool; + const stableSelectTool = useCallback( + (id: ToolId | null) => selectToolRef.current(id), + [], + ); + + const stableClearToolSelection = useCallback( + () => selectToolRef.current(null), + [], + ); + + const actionsValue = useMemo( + () => ({ + selectTool: stableSelectTool, + clearToolSelection: stableClearToolSelection, + toggleFavorite, + handleToolSelect: stableHandleToolSelect, + handleToolSelectForced: stableHandleToolSelectForced, + handleBackToTools: stableHandleBackToTools, + handleReaderToggle: stableHandleReaderToggle, + setSidebarsVisible, + setLeftPanelView, + setReaderMode: stableSetReaderMode, + setToolPanelMode: stableSetToolPanelMode, + setPreviewFile: stableSetPreviewFile, + setPageEditorFunctions, + setSearchQuery, + registerToolReset, + resetTool, + }), + [ + stableSelectTool, + stableClearToolSelection, + toggleFavorite, + stableHandleToolSelect, + stableHandleToolSelectForced, + stableHandleBackToTools, + stableHandleReaderToggle, + setSidebarsVisible, + setLeftPanelView, + stableSetReaderMode, + stableSetToolPanelMode, + stableSetPreviewFile, + setPageEditorFunctions, + setSearchQuery, + registerToolReset, + resetTool, + ], + ); + + const dataValue = useMemo( + () => ({ + toolAvailability, + toolRegistry, + favoriteTools, + getSelectedTool, + isFavorite, + }), + [ + toolAvailability, + toolRegistry, + favoriteTools, + getSelectedTool, + isFavorite, + ], + ); + // Properly memoized context value const contextValue = useMemo( (): ToolWorkflowContextValue => ({ @@ -617,12 +776,38 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { ); return ( - - {children} - + + + + {children} + + + ); } +/** Tool-workflow callbacks with referentially-stable identities. */ +export function useToolWorkflowActions(): ToolWorkflowActionsValue { + const ctx = useContext(ToolWorkflowActionsContext); + if (!ctx) { + throw new Error( + "useToolWorkflowActions must be used within ToolWorkflowProvider", + ); + } + return ctx; +} + +/** Tool registry, availability, and favorites — stable while unchanged. */ +export function useToolWorkflowData(): ToolWorkflowDataValue { + const ctx = useContext(ToolWorkflowDataContext); + if (!ctx) { + throw new Error( + "useToolWorkflowData must be used within ToolWorkflowProvider", + ); + } + return ctx; +} + // Custom hook to use the context export function useToolWorkflow(): ToolWorkflowContextValue { const context = useContext(ToolWorkflowContext); diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index da9377b8a..87f9b2f88 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -177,11 +177,7 @@ export const useToolManagement = (): ToolManagementResult => { if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) { return; } - availableToolRegistry[toolKey] = { - ...baseTool, - name: baseTool.name, - description: baseTool.description, - }; + availableToolRegistry[toolKey] = baseTool; }); return availableToolRegistry; }, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]); diff --git a/frontend/src/core/hooks/useToolNavigation.ts b/frontend/src/core/hooks/useToolNavigation.ts index e733850ac..5e40000fa 100644 --- a/frontend/src/core/hooks/useToolNavigation.ts +++ b/frontend/src/core/hooks/useToolNavigation.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { ToolRegistryEntry, getToolUrlPath } from "@app/data/toolsTaxonomy"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useToolWorkflowActions } from "@app/contexts/ToolWorkflowContext"; import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; import { ToolId } from "@app/types/toolId"; @@ -21,7 +21,7 @@ export function useToolNavigation(): { tool: ToolRegistryEntry, ) => ToolNavigationProps; } { - const { handleToolSelect } = useToolWorkflow(); + const { handleToolSelect } = useToolWorkflowActions(); const getToolNavigation = useCallback( (toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => { diff --git a/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx index ba1ecf8e0..39b71f9d3 100644 --- a/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect } from "react"; +import { memo, useState, useEffect } from "react"; import CoreToolButton from "@core/components/tools/toolPicker/ToolButton"; import { getToolDisabledReason } from "@app/components/tools/fullscreen/shared"; -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { + useToolWorkflowActions, + useToolWorkflowData, +} from "@app/contexts/ToolWorkflowContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { connectionModeService, @@ -18,7 +21,8 @@ type CoreToolButtonProps = React.ComponentProps; * In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge). */ const ToolButton: React.FC = (props) => { - const { toolAvailability, handleToolSelectForced } = useToolWorkflow(); + const { toolAvailability } = useToolWorkflowData(); + const { handleToolSelectForced } = useToolWorkflowActions(); const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; const [connectionMode, setConnectionMode] = useState( @@ -55,4 +59,4 @@ const ToolButton: React.FC = (props) => { ); }; -export default ToolButton; +export default memo(ToolButton);