mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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 { 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,
|
config,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
};
|
}),
|
||||||
|
[config, loading, error, refetch],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppConfigContext.Provider value={value}>
|
<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 {
|
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 (
|
||||||
|
<ToolWorkflowActionsContext.Provider value={actionsValue}>
|
||||||
|
<ToolWorkflowDataContext.Provider value={dataValue}>
|
||||||
<ToolWorkflowContext.Provider value={contextValue}>
|
<ToolWorkflowContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</ToolWorkflowContext.Provider>
|
</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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user