perf(frontend): stabilize hot-path context subscriptions to fix excessive rerenders (#6373)

## 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.
This commit is contained in:
ConnorYoh
2026-05-19 15:56:50 +00:00
committed by GitHub
parent f9fbc37800
commit 31f6ea4b25
11 changed files with 284 additions and 77 deletions
@@ -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<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
@@ -209,7 +219,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
onSelect={handleSelect}
searchQuery={searchQuery}
/>
</div>
@@ -217,7 +227,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
onSelect={handleSelect}
filteredTools={filteredTools}
isSearching={Boolean(
searchQuery && searchQuery.trim().length > 0,
@@ -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<HTMLDivElement>(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 (
<Box
h="100%"
style={{
display: "flex",
flexDirection: "column",
background: "var(--bg-toolbar)",
}}
>
<Box h="100%" style={CONTAINER_STYLE}>
<Box
ref={scrollableRef}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
height: "100%",
marginTop: -2,
}}
style={SCROLLABLE_STYLE}
className="tool-picker-scrollable"
>
{isSearching ? (
@@ -124,7 +124,7 @@ const ToolPicker = ({
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
<div style={HEADER_TEXT_STYLE}>
{t("toolPanel.fullscreen.favorites", "Favourites")}
</div>
<div>
@@ -143,7 +143,7 @@ const ToolPicker = ({
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
<div style={HEADER_TEXT_STYLE}>
{t("toolPanel.fullscreen.recommended", "Recommended")}
</div>
<div>
@@ -163,7 +163,7 @@ const ToolPicker = ({
{allSection &&
allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
<div style={HEADER_TEXT_STYLE}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div>
{renderToolButtons(
@@ -192,4 +192,4 @@ const ToolPicker = ({
);
};
export default ToolPicker;
export default memo(ToolPicker);
@@ -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<ToolButtonProps> = ({
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<ToolButtonProps> = ({
);
};
export default ToolButton;
export default memo(ToolButton);
@@ -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 (
<ActiveDocumentContext.Provider value={{ documentId }}>
<ActiveDocumentContext.Provider value={value}>
{children}
</ActiveDocumentContext.Provider>
);
@@ -1,6 +1,7 @@
import React, {
createContext,
useContext,
useMemo,
useState,
useEffect,
ReactNode,
@@ -219,12 +220,15 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
const value: AppConfigContextValue = {
config,
loading,
error,
refetch,
};
const value = useMemo<AppConfigContextValue>(
() => ({
config,
loading,
error,
refetch,
}),
[config, loading, error, refetch],
);
return (
<AppConfigContext.Provider value={value}>
+4 -4
View File
@@ -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<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
const value = useMemo(() => ({ banner, setBanner }), [banner]);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
<BannerContext.Provider value={value}>{children}</BannerContext.Provider>
);
}
@@ -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 (
<CommentAuthorContext.Provider value={{ displayName }}>
<CommentAuthorContext.Provider value={value}>
{children}
</CommentAuthorContext.Provider>
);
@@ -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<ToolRegistry>;
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<ToolWorkflowActionsValue>(
() => ({
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<ToolWorkflowDataValue>(
() => ({
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 (
<ToolWorkflowContext.Provider value={contextValue}>
{children}
</ToolWorkflowContext.Provider>
<ToolWorkflowActionsContext.Provider value={actionsValue}>
<ToolWorkflowDataContext.Provider value={dataValue}>
<ToolWorkflowContext.Provider value={contextValue}>
{children}
</ToolWorkflowContext.Provider>
</ToolWorkflowDataContext.Provider>
</ToolWorkflowActionsContext.Provider>
);
}
/** 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);
@@ -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]);
+2 -2
View File
@@ -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 => {
@@ -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<typeof CoreToolButton>;
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
*/
const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
const { toolAvailability, handleToolSelectForced } = useToolWorkflow();
const { toolAvailability } = useToolWorkflowData();
const { handleToolSelectForced } = useToolWorkflowActions();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
@@ -55,4 +59,4 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
);
};
export default ToolButton;
export default memo(ToolButton);