+
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
{renderToolButtons(
@@ -192,4 +192,4 @@ const ToolPicker = ({
);
};
-export default ToolPicker;
+export default memo(ToolPicker);
diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx
index d4021d794a..a0c01c6449 100644
--- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx
+++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { memo } from "react";
import { Button, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@app/components/shared/Tooltip";
@@ -10,7 +10,10 @@ import FitText from "@app/components/shared/FitText";
import { useHotkeys } from "@app/contexts/HotkeyContext";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
-import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+import {
+ useToolWorkflowActions,
+ useToolWorkflowData,
+} from "@app/contexts/ToolWorkflowContext";
import type { ToolId } from "@app/types/toolId";
import {
getToolDisabledReason,
@@ -46,7 +49,8 @@ const ToolButton: React.FC
= ({
const { t } = useTranslation();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
- const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
+ const { isFavorite, toolAvailability } = useToolWorkflowData();
+ const { toggleFavorite } = useToolWorkflowActions();
const disabledReason = getToolDisabledReason(
id,
tool,
@@ -309,4 +313,4 @@ const ToolButton: React.FC = ({
);
};
-export default ToolButton;
+export default memo(ToolButton);
diff --git a/frontend/src/core/components/viewer/ActiveDocumentContext.tsx b/frontend/src/core/components/viewer/ActiveDocumentContext.tsx
index a9029abda8..07c035593c 100644
--- a/frontend/src/core/components/viewer/ActiveDocumentContext.tsx
+++ b/frontend/src/core/components/viewer/ActiveDocumentContext.tsx
@@ -1,6 +1,7 @@
import React, {
createContext,
useContext,
+ useMemo,
useState,
useEffect,
useRef,
@@ -63,8 +64,10 @@ export function ActiveDocumentProvider({
};
}, []);
+ const value = useMemo(() => ({ documentId }), [documentId]);
+
return (
-
+
{children}
);
diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx
index 021508c36c..7635345540 100644
--- a/frontend/src/core/contexts/AppConfigContext.tsx
+++ b/frontend/src/core/contexts/AppConfigContext.tsx
@@ -1,6 +1,7 @@
import React, {
createContext,
useContext,
+ useMemo,
useState,
useEffect,
ReactNode,
@@ -219,12 +220,15 @@ export const AppConfigProvider: React.FC = ({
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
- const value: AppConfigContextValue = {
- config,
- loading,
- error,
- refetch,
- };
+ const value = useMemo(
+ () => ({
+ config,
+ loading,
+ error,
+ refetch,
+ }),
+ [config, loading, error, refetch],
+ );
return (
diff --git a/frontend/src/core/contexts/BannerContext.tsx b/frontend/src/core/contexts/BannerContext.tsx
index 241387fc02..f910fe5ec9 100644
--- a/frontend/src/core/contexts/BannerContext.tsx
+++ b/frontend/src/core/contexts/BannerContext.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useState, ReactNode } from "react";
+import { createContext, useContext, useMemo, useState, ReactNode } from "react";
interface BannerContextType {
banner: ReactNode;
@@ -10,10 +10,10 @@ const BannerContext = createContext(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState(null);
+ const value = useMemo(() => ({ banner, setBanner }), [banner]);
+
return (
-
- {children}
-
+ {children}
);
}
diff --git a/frontend/src/core/contexts/CommentAuthorContext.tsx b/frontend/src/core/contexts/CommentAuthorContext.tsx
index 1378d628f9..9c3f9cc2ff 100644
--- a/frontend/src/core/contexts/CommentAuthorContext.tsx
+++ b/frontend/src/core/contexts/CommentAuthorContext.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, type ReactNode } from "react";
+import { createContext, useContext, useMemo, type ReactNode } from "react";
export interface CommentAuthorValue {
displayName: string;
@@ -15,8 +15,9 @@ export function CommentAuthorProvider({
children: ReactNode;
displayName?: string;
}) {
+ const value = useMemo(() => ({ displayName }), [displayName]);
return (
-
+
{children}
);
diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx
index 60f4124aaf..6bc61fa294 100644
--- a/frontend/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx
@@ -10,6 +10,7 @@ import React, {
useCallback,
useMemo,
useEffect,
+ useRef,
} from "react";
import {
useToolManagement,
@@ -122,6 +123,46 @@ if (!existingContext) {
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
}
+/**
+ * Slim subset contexts for hot consumers that don't need the full state.
+ *
+ * - `useToolWorkflowActions()` — referentially-stable callbacks.
+ * - `useToolWorkflowData()` — tool registry, availability, favorites.
+ */
+export interface ToolWorkflowActionsValue {
+ selectTool: (toolId: ToolId | null) => void;
+ clearToolSelection: () => void;
+ toggleFavorite: (toolId: ToolId) => void;
+ handleToolSelect: (toolId: ToolId) => void;
+ handleToolSelectForced: (toolId: ToolId) => void;
+ handleBackToTools: () => void;
+ handleReaderToggle: () => void;
+ setSidebarsVisible: (visible: boolean) => void;
+ setLeftPanelView: (view: "toolPicker" | "toolContent" | "hidden") => void;
+ setReaderMode: (mode: boolean) => void;
+ setToolPanelMode: (mode: ToolPanelMode) => void;
+ setPreviewFile: (file: File | null) => void;
+ setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
+ setSearchQuery: (query: string) => void;
+ registerToolReset: (toolId: string, resetFunction: () => void) => void;
+ resetTool: (toolId: string) => void;
+}
+
+export interface ToolWorkflowDataValue {
+ toolAvailability: ToolAvailabilityMap;
+ toolRegistry: Partial;
+ favoriteTools: ToolId[];
+ getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
+ isFavorite: (toolId: ToolId) => boolean;
+}
+
+const ToolWorkflowActionsContext = createContext<
+ ToolWorkflowActionsValue | undefined
+>(undefined);
+const ToolWorkflowDataContext = createContext<
+ ToolWorkflowDataValue | undefined
+>(undefined);
+
// Provider component
interface ToolWorkflowProviderProps {
children: React.ReactNode;
@@ -534,6 +575,124 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
true,
);
+ // Ref-backed wrappers so callback identities stay stable across renders.
+ const handleToolSelectRef = useRef(handleToolSelect);
+ handleToolSelectRef.current = handleToolSelect;
+ const stableHandleToolSelect = useCallback(
+ (id: ToolId) => handleToolSelectRef.current(id),
+ [],
+ );
+
+ const handleToolSelectForcedRef = useRef(handleToolSelectForced);
+ handleToolSelectForcedRef.current = handleToolSelectForced;
+ const stableHandleToolSelectForced = useCallback(
+ (id: ToolId) => handleToolSelectForcedRef.current(id),
+ [],
+ );
+
+ const handleBackToToolsRef = useRef(handleBackToTools);
+ handleBackToToolsRef.current = handleBackToTools;
+ const stableHandleBackToTools = useCallback(
+ () => handleBackToToolsRef.current(),
+ [],
+ );
+
+ const handleReaderToggleRef = useRef(handleReaderToggle);
+ handleReaderToggleRef.current = handleReaderToggle;
+ const stableHandleReaderToggle = useCallback(
+ () => handleReaderToggleRef.current(),
+ [],
+ );
+
+ const setReaderModeRef = useRef(setReaderMode);
+ setReaderModeRef.current = setReaderMode;
+ const stableSetReaderMode = useCallback(
+ (mode: boolean) => setReaderModeRef.current(mode),
+ [],
+ );
+
+ const setPreviewFileRef = useRef(setPreviewFile);
+ setPreviewFileRef.current = setPreviewFile;
+ const stableSetPreviewFile = useCallback(
+ (file: File | null) => setPreviewFileRef.current(file),
+ [],
+ );
+
+ const setToolPanelModeRef = useRef(setToolPanelMode);
+ setToolPanelModeRef.current = setToolPanelMode;
+ const stableSetToolPanelMode = useCallback(
+ (mode: ToolPanelMode) => setToolPanelModeRef.current(mode),
+ [],
+ );
+
+ const selectToolRef = useRef(actions.setSelectedTool);
+ selectToolRef.current = actions.setSelectedTool;
+ const stableSelectTool = useCallback(
+ (id: ToolId | null) => selectToolRef.current(id),
+ [],
+ );
+
+ const stableClearToolSelection = useCallback(
+ () => selectToolRef.current(null),
+ [],
+ );
+
+ const actionsValue = useMemo(
+ () => ({
+ selectTool: stableSelectTool,
+ clearToolSelection: stableClearToolSelection,
+ toggleFavorite,
+ handleToolSelect: stableHandleToolSelect,
+ handleToolSelectForced: stableHandleToolSelectForced,
+ handleBackToTools: stableHandleBackToTools,
+ handleReaderToggle: stableHandleReaderToggle,
+ setSidebarsVisible,
+ setLeftPanelView,
+ setReaderMode: stableSetReaderMode,
+ setToolPanelMode: stableSetToolPanelMode,
+ setPreviewFile: stableSetPreviewFile,
+ setPageEditorFunctions,
+ setSearchQuery,
+ registerToolReset,
+ resetTool,
+ }),
+ [
+ stableSelectTool,
+ stableClearToolSelection,
+ toggleFavorite,
+ stableHandleToolSelect,
+ stableHandleToolSelectForced,
+ stableHandleBackToTools,
+ stableHandleReaderToggle,
+ setSidebarsVisible,
+ setLeftPanelView,
+ stableSetReaderMode,
+ stableSetToolPanelMode,
+ stableSetPreviewFile,
+ setPageEditorFunctions,
+ setSearchQuery,
+ registerToolReset,
+ resetTool,
+ ],
+ );
+
+ const dataValue = useMemo(
+ () => ({
+ toolAvailability,
+ toolRegistry,
+ favoriteTools,
+ getSelectedTool,
+ isFavorite,
+ }),
+ [
+ toolAvailability,
+ toolRegistry,
+ favoriteTools,
+ getSelectedTool,
+ isFavorite,
+ ],
+ );
+
// Properly memoized context value
const contextValue = useMemo(
(): ToolWorkflowContextValue => ({
@@ -617,12 +776,38 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
);
return (
-
- {children}
-
+
+
+
+ {children}
+
+
+
);
}
+/** Tool-workflow callbacks with referentially-stable identities. */
+export function useToolWorkflowActions(): ToolWorkflowActionsValue {
+ const ctx = useContext(ToolWorkflowActionsContext);
+ if (!ctx) {
+ throw new Error(
+ "useToolWorkflowActions must be used within ToolWorkflowProvider",
+ );
+ }
+ return ctx;
+}
+
+/** Tool registry, availability, and favorites — stable while unchanged. */
+export function useToolWorkflowData(): ToolWorkflowDataValue {
+ const ctx = useContext(ToolWorkflowDataContext);
+ if (!ctx) {
+ throw new Error(
+ "useToolWorkflowData must be used within ToolWorkflowProvider",
+ );
+ }
+ return ctx;
+}
+
// Custom hook to use the context
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx
index da9377b8ae..87f9b2f889 100644
--- a/frontend/src/core/hooks/useToolManagement.tsx
+++ b/frontend/src/core/hooks/useToolManagement.tsx
@@ -177,11 +177,7 @@ export const useToolManagement = (): ToolManagementResult => {
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
}
- availableToolRegistry[toolKey] = {
- ...baseTool,
- name: baseTool.name,
- description: baseTool.description,
- };
+ availableToolRegistry[toolKey] = baseTool;
});
return availableToolRegistry;
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
diff --git a/frontend/src/core/hooks/useToolNavigation.ts b/frontend/src/core/hooks/useToolNavigation.ts
index e733850ac9..5e40000fa6 100644
--- a/frontend/src/core/hooks/useToolNavigation.ts
+++ b/frontend/src/core/hooks/useToolNavigation.ts
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { ToolRegistryEntry, getToolUrlPath } from "@app/data/toolsTaxonomy";
-import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+import { useToolWorkflowActions } from "@app/contexts/ToolWorkflowContext";
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
import { ToolId } from "@app/types/toolId";
@@ -21,7 +21,7 @@ export function useToolNavigation(): {
tool: ToolRegistryEntry,
) => ToolNavigationProps;
} {
- const { handleToolSelect } = useToolWorkflow();
+ const { handleToolSelect } = useToolWorkflowActions();
const getToolNavigation = useCallback(
(toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
diff --git a/frontend/src/core/styles/index.css b/frontend/src/core/styles/index.css
index 9298fd4147..66a98cc8c8 100644
--- a/frontend/src/core/styles/index.css
+++ b/frontend/src/core/styles/index.css
@@ -15,6 +15,11 @@ body {
-moz-osx-font-smoothing: grayscale;
}
+img {
+ -webkit-user-drag: none;
+ user-drag: none;
+}
+
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
diff --git a/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx
index ba1ecf8e0e..39b71f9d38 100644
--- a/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx
+++ b/frontend/src/desktop/components/tools/toolPicker/ToolButton.tsx
@@ -1,7 +1,10 @@
-import { useState, useEffect } from "react";
+import { memo, useState, useEffect } from "react";
import CoreToolButton from "@core/components/tools/toolPicker/ToolButton";
import { getToolDisabledReason } from "@app/components/tools/fullscreen/shared";
-import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+import {
+ useToolWorkflowActions,
+ useToolWorkflowData,
+} from "@app/contexts/ToolWorkflowContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
connectionModeService,
@@ -18,7 +21,8 @@ type CoreToolButtonProps = React.ComponentProps;
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
*/
const ToolButton: React.FC = (props) => {
- const { toolAvailability, handleToolSelectForced } = useToolWorkflow();
+ const { toolAvailability } = useToolWorkflowData();
+ const { handleToolSelectForced } = useToolWorkflowActions();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState(
@@ -55,4 +59,4 @@ const ToolButton: React.FC = (props) => {
);
};
-export default ToolButton;
+export default memo(ToolButton);
diff --git a/scripts/find-free-port.ps1 b/scripts/find-free-port.ps1
index 7b6697456d..241117a148 100644
--- a/scripts/find-free-port.ps1
+++ b/scripts/find-free-port.ps1
@@ -4,7 +4,7 @@
# emits a random free port in 20000-49999. Probes by attempting to bind a
# TcpListener on loopback. Tracks picks within this run so outputs are
# guaranteed distinct from each other.
-param([int[]]$Preferred)
+param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred)
$script:picked = @()
diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml
index fef3648fbc..e6fcf48f17 100644
--- a/scripts/ignore_translation.toml
+++ b/scripts/ignore_translation.toml
@@ -178,12 +178,115 @@ ignore = [
[es_ES]
ignore = [
+ 'adjustPageScale.pageSize.legal',
+ 'admin.error',
+ 'admin.settings.advanced.processExecutor.calibre',
+ 'admin.settings.advanced.processExecutor.ghostscript',
+ 'admin.settings.advanced.processExecutor.libreOffice',
+ 'admin.settings.advanced.processExecutor.ocrMyPdf',
+ 'admin.settings.advanced.processExecutor.pythonOpenCv',
+ 'admin.settings.advanced.processExecutor.qpdf',
+ 'admin.settings.advanced.processExecutor.tesseract',
+ 'admin.settings.advanced.processExecutor.weasyPrint',
+ 'admin.settings.connections.mobileScanner.pageFormatA4',
+ 'admin.settings.connections.oauth2.label',
+ 'admin.settings.connections.pageFormatA4',
+ 'admin.settings.connections.saml2.label',
+ 'admin.settings.security.xFrameOptions.label',
+ 'adminUserSettings.roles',
+ 'audit.clearData.irreversible',
+ 'certSign.appearance.invisible',
+ 'certSign.appearance.visible',
+ 'certSign.collab.participant.certInvalid',
+ 'certSign.collab.signRequest.steps.invisible',
+ 'certSign.collab.signRequest.steps.visible',
+ 'certSign.collab.signRequest.text.colorLabel',
+ 'certSign.collab.signRequest.text.fontSizePlaceholder',
+ 'common.error',
+ 'compare.pixel.base',
+ 'config.account.profilePicture.cropper.zoom',
+ 'config.overview.error',
+ 'config.plan',
+ 'convert.cbrToPdf',
+ 'convert.cbzToPdf',
+ 'convert.dpi',
+ 'convert.ebookToPdf',
+ 'convert.markdown',
+ 'convert.pdfToCbr',
+ 'convert.pdfToCbz',
+ 'convert.pdfToEpub',
+ 'convert.pptExt',
+ 'convert.svgToPdf',
+ 'cookieBanner.services.posthog',
+ 'cookieBanner.services.scarf',
+ 'credits.modal.enterpriseSubscription',
+ 'credits.modal.popular',
+ 'credits.modal.teamSubscription',
+ 'defaultApp.error.title',
+ 'endpointStatistics.dataTypeApi',
+ 'endpointStatistics.dataTypeUi',
+ 'endpointStatistics.top10',
+ 'endpointStatistics.top20',
+ 'error._value',
+ 'fileManager.filterLocal',
+ 'fileManager.googleDrive',
+ 'fileManager.googleDriveShort',
+ 'firstLogin.error',
+ 'footer.discord',
+ 'footer.issues',
+ 'getPdfInfo.other.javaScript',
+ 'getPdfInfo.perPage.multimedia',
+ 'getPdfInfo.summary.tech.javaScript',
+ 'getPdfInfo.summary.tech.multimedia',
+ 'home.devApi.title',
+ 'landing.heroTitle',
'language.direction',
+ 'mobileScanner.flash',
'no',
+ 'pdfTextEditor.groupingMode.auto',
+ 'plan.enterprise.name',
+ 'plan.popular',
+ 'plan.pro.name',
+ 'plan.team.name',
+ 'plan.trial.message',
'pro',
+ 'provider.googledrive.name',
+ 'provider.oauth2.generic.scope',
+ 'provider.oauth2.keycloak.scope',
+ 'provider.saml2.name',
+ 'provider.saml2.scope',
+ 'quickAccess.accessRoleEditor',
+ 'redact.manual.zoom',
+ 'redact.modeSelector.manual',
+ 'removeBlanks.whitePercent.unit',
+ 'settings.configuration.endpoints',
+ 'settings.connection.mode.saas',
+ 'settings.general.title',
+ 'settings.licensingAnalytics.plan',
+ 'settings.planBilling.tier.enterprise',
+ 'settings.planBilling.tier.team',
+ 'settings.planBilling.tier.teamBadge',
+ 'settings.policiesPrivacy.legal',
+ 'settings.securityAuth.telegram',
+ 'setup.mode.saas.title',
+ 'setup.server.type.saas',
'showJS.tags',
+ 'storageShare.roleEditor',
+ 'team.personal',
+ 'toolPicker.subcategories.general',
'update.priority.normal',
+ 'usage.controls.dataType.api',
+ 'usage.controls.dataType.ui',
+ 'usage.controls.top10',
+ 'usage.controls.top20',
+ 'usage.showing.top10',
+ 'usage.showing.top20',
'validateSignature.cert.bits',
+ 'validateSignature.downloadType.csv',
+ 'validateSignature.downloadType.json',
+ 'validateSignature.downloadType.pdf',
+ 'workspace.people.authType.oauth',
+ 'workspace.people.authType.saml',
]
[eu_ES]
diff --git a/testing/cucumber/requirements.txt b/testing/cucumber/requirements.txt
index 93e38e0e7d..537c16f65c 100644
--- a/testing/cucumber/requirements.txt
+++ b/testing/cucumber/requirements.txt
@@ -315,9 +315,9 @@ pycryptodome==3.23.0 \
--hash=sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be \
--hash=sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7
# via -r requirements.in
-pypdf==6.10.2 \
- --hash=sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d \
- --hash=sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa
+pypdf==6.11.0 \
+ --hash=sha256:062b51c81b0910e6d2755e99e1c5547a0a23b7d0a32322af66240d8edcfabe87 \
+ --hash=sha256:769394d5756d5b304c9b6bef88b54b1816b328e7e6fc9254e625529a15ed4ab8
# via -r requirements.in
qrcode==8.2 \
--hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f \
@@ -337,7 +337,7 @@ six==1.17.0 \
# via
# behave
# parse-type
-urllib3==2.6.3 \
- --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
- --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
+urllib3==2.7.0 \
+ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
+ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests