mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user