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 { 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 { usePreferences } from "@app/contexts/PreferencesContext";
import ToolPicker from "@app/components/tools/ToolPicker"; import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults"; import SearchResults from "@app/components/tools/SearchResults";
@@ -39,17 +42,19 @@ export default function ToolPanel() {
toolRegistry, toolRegistry,
setSearchQuery, setSearchQuery,
selectedToolKey, selectedToolKey,
toolPanelMode,
sidebarsVisible,
readerMode,
} = useToolWorkflow();
const {
handleToolSelect, handleToolSelect,
handleBackToTools, handleBackToTools,
setPreviewFile, setPreviewFile,
toolPanelMode,
setToolPanelMode, setToolPanelMode,
setLeftPanelView, setLeftPanelView,
setReaderMode, setReaderMode,
setSidebarsVisible, setSidebarsVisible,
sidebarsVisible, } = useToolWorkflowActions();
readerMode,
} = useToolWorkflow();
const { setAllButtonsDisabled } = useWorkbenchBar(); const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences(); const { preferences, updatePreference } = usePreferences();
@@ -114,6 +119,11 @@ export default function ToolPanel() {
return "18.5rem"; return "18.5rem";
}; };
const handleSelect = useCallback(
(id: string) => handleToolSelect(id as ToolId),
[handleToolSelect],
);
const matchedTextMap = useMemo(() => { const matchedTextMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => { filteredTools.forEach(({ item: [id], matchedText }) => {
@@ -209,7 +219,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-y-auto"> <div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults <SearchResults
filteredTools={filteredTools} filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)} onSelect={handleSelect}
searchQuery={searchQuery} searchQuery={searchQuery}
/> />
</div> </div>
@@ -217,7 +227,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-auto"> <div className="flex-1 flex flex-col overflow-auto">
<ToolPicker <ToolPicker
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)} onSelect={handleSelect}
filteredTools={filteredTools} filteredTools={filteredTools}
isSearching={Boolean( isSearching={Boolean(
searchQuery && searchQuery.trim().length > 0, 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 { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; 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 NoToolsFound from "@app/components/tools/shared/NoToolsFound";
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons"; import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
import ToolButton from "@app/components/tools/toolPicker/ToolButton"; 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 { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions"; import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions";
@@ -24,6 +24,34 @@ interface ToolPickerProps {
isSearching?: boolean; 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 = ({ const ToolPicker = ({
selectedToolKey, selectedToolKey,
onSelect, onSelect,
@@ -35,7 +63,7 @@ const ToolPicker = ({
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const { sections: visibleSections } = useToolSections(filteredTools); const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow(); const { favoriteTools, toolRegistry } = useToolWorkflowData();
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
@@ -60,43 +88,15 @@ const ToolPicker = ({
); );
// Build flat list by subcategory for search mode // Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps["filteredTools"] = [];
const effectiveFilteredForSearch: ToolPickerProps["filteredTools"] = const effectiveFilteredForSearch: ToolPickerProps["filteredTools"] =
isSearching ? filteredTools : emptyFilteredTools; isSearching ? filteredTools : EMPTY_FILTERED_TOOLS;
const { searchGroups } = useToolSections(effectiveFilteredForSearch); 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 ( return (
<Box <Box h="100%" style={CONTAINER_STYLE}>
h="100%"
style={{
display: "flex",
flexDirection: "column",
background: "var(--bg-toolbar)",
}}
>
<Box <Box
ref={scrollableRef} ref={scrollableRef}
style={{ style={SCROLLABLE_STYLE}
flex: 1,
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
height: "100%",
marginTop: -2,
}}
className="tool-picker-scrollable" className="tool-picker-scrollable"
> >
{isSearching ? ( {isSearching ? (
@@ -124,7 +124,7 @@ const ToolPicker = ({
<Stack p="sm" gap="xs"> <Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && ( {favoriteToolItems.length > 0 && (
<Box w="100%"> <Box w="100%">
<div style={headerTextStyle}> <div style={HEADER_TEXT_STYLE}>
{t("toolPanel.fullscreen.favorites", "Favourites")} {t("toolPanel.fullscreen.favorites", "Favourites")}
</div> </div>
<div> <div>
@@ -143,7 +143,7 @@ const ToolPicker = ({
)} )}
{recommendedItems.length > 0 && ( {recommendedItems.length > 0 && (
<Box w="100%"> <Box w="100%">
<div style={headerTextStyle}> <div style={HEADER_TEXT_STYLE}>
{t("toolPanel.fullscreen.recommended", "Recommended")} {t("toolPanel.fullscreen.recommended", "Recommended")}
</div> </div>
<div> <div>
@@ -163,7 +163,7 @@ const ToolPicker = ({
{allSection && {allSection &&
allSection.subcategories.map((sc: SubcategoryGroup) => ( allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%"> <Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}> <div style={HEADER_TEXT_STYLE}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))} {toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div> </div>
{renderToolButtons( {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 { Button, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "@app/components/shared/Tooltip"; 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 { useHotkeys } from "@app/contexts/HotkeyContext";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; 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 type { ToolId } from "@app/types/toolId";
import { import {
getToolDisabledReason, getToolDisabledReason,
@@ -46,7 +49,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled; const premiumEnabled = config?.premiumEnabled;
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow(); const { isFavorite, toolAvailability } = useToolWorkflowData();
const { toggleFavorite } = useToolWorkflowActions();
const disabledReason = getToolDisabledReason( const disabledReason = getToolDisabledReason(
id, id,
tool, tool,
@@ -309,4 +313,4 @@ const ToolButton: React.FC<ToolButtonProps> = ({
); );
}; };
export default ToolButton; export default memo(ToolButton);
@@ -1,6 +1,7 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useMemo,
useState, useState,
useEffect, useEffect,
useRef, useRef,
@@ -63,8 +64,10 @@ export function ActiveDocumentProvider({
}; };
}, []); }, []);
const value = useMemo(() => ({ documentId }), [documentId]);
return ( return (
<ActiveDocumentContext.Provider value={{ documentId }}> <ActiveDocumentContext.Provider value={value}>
{children} {children}
</ActiveDocumentContext.Provider> </ActiveDocumentContext.Provider>
); );
@@ -1,6 +1,7 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useMemo,
useState, useState,
useEffect, useEffect,
ReactNode, ReactNode,
@@ -219,12 +220,15 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
const value: AppConfigContextValue = { const value = useMemo<AppConfigContextValue>(
config, () => ({
loading, config,
error, loading,
refetch, error,
}; refetch,
}),
[config, loading, error, refetch],
);
return ( return (
<AppConfigContext.Provider value={value}> <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 { interface BannerContextType {
banner: ReactNode; banner: ReactNode;
@@ -10,10 +10,10 @@ const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) { export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null); const [banner, setBanner] = useState<ReactNode>(null);
const value = useMemo(() => ({ banner, setBanner }), [banner]);
return ( return (
<BannerContext.Provider value={{ banner, setBanner }}> <BannerContext.Provider value={value}>{children}</BannerContext.Provider>
{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 { export interface CommentAuthorValue {
displayName: string; displayName: string;
@@ -15,8 +15,9 @@ export function CommentAuthorProvider({
children: ReactNode; children: ReactNode;
displayName?: string; displayName?: string;
}) { }) {
const value = useMemo(() => ({ displayName }), [displayName]);
return ( return (
<CommentAuthorContext.Provider value={{ displayName }}> <CommentAuthorContext.Provider value={value}>
{children} {children}
</CommentAuthorContext.Provider> </CommentAuthorContext.Provider>
); );
@@ -10,6 +10,7 @@ import React, {
useCallback, useCallback,
useMemo, useMemo,
useEffect, useEffect,
useRef,
} from "react"; } from "react";
import { import {
useToolManagement, useToolManagement,
@@ -122,6 +123,46 @@ if (!existingContext) {
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext; (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 // Provider component
interface ToolWorkflowProviderProps { interface ToolWorkflowProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -534,6 +575,124 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
true, 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 // Properly memoized context value
const contextValue = useMemo( const contextValue = useMemo(
(): ToolWorkflowContextValue => ({ (): ToolWorkflowContextValue => ({
@@ -617,12 +776,38 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
); );
return ( return (
<ToolWorkflowContext.Provider value={contextValue}> <ToolWorkflowActionsContext.Provider value={actionsValue}>
{children} <ToolWorkflowDataContext.Provider value={dataValue}>
</ToolWorkflowContext.Provider> <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 // Custom hook to use the context
export function useToolWorkflow(): ToolWorkflowContextValue { export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext); const context = useContext(ToolWorkflowContext);
@@ -177,11 +177,7 @@ export const useToolManagement = (): ToolManagementResult => {
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) { if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return; return;
} }
availableToolRegistry[toolKey] = { availableToolRegistry[toolKey] = baseTool;
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
}); });
return availableToolRegistry; return availableToolRegistry;
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]); }, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { ToolRegistryEntry, getToolUrlPath } from "@app/data/toolsTaxonomy"; 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 { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
import { ToolId } from "@app/types/toolId"; import { ToolId } from "@app/types/toolId";
@@ -21,7 +21,7 @@ export function useToolNavigation(): {
tool: ToolRegistryEntry, tool: ToolRegistryEntry,
) => ToolNavigationProps; ) => ToolNavigationProps;
} { } {
const { handleToolSelect } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflowActions();
const getToolNavigation = useCallback( const getToolNavigation = useCallback(
(toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => { (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 CoreToolButton from "@core/components/tools/toolPicker/ToolButton";
import { getToolDisabledReason } from "@app/components/tools/fullscreen/shared"; 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 { useAppConfig } from "@app/contexts/AppConfigContext";
import { import {
connectionModeService, connectionModeService,
@@ -18,7 +21,8 @@ type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge). * In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
*/ */
const ToolButton: React.FC<CoreToolButtonProps> = (props) => { const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
const { toolAvailability, handleToolSelectForced } = useToolWorkflow(); const { toolAvailability } = useToolWorkflowData();
const { handleToolSelectForced } = useToolWorkflowActions();
const { config } = useAppConfig(); const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled; const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>( const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
@@ -55,4 +59,4 @@ const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
); );
}; };
export default ToolButton; export default memo(ToolButton);