From 2beb85dd20e3ac6687c56d6e80a1bd8e36503f21 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 29 Sep 2026 20:15:40 +0530 Subject: [PATCH] UN-4187 Standardise optional plugin loading with a single loadPlugin helper Every non-route plugin import used a module-level `try { await import("../plugins/...") } catch {}`, most with a bare catch, so a real chunk-load or runtime error was indistinguishable from "plugin not installed". Fallbacks differed per site, and several sites shared one try block so a single missing plugin disabled the others. Add loadPlugin(importer, fallback) to helpers/pluginLoader.js: it returns the fallback silently when the plugin is absent and logs every other failure once. Absence is now isPluginAbsent, which is narrower than the old isModuleMissing: a chunk that failed to fetch belongs to a plugin that IS shipped, so it is logged instead of mistaken for absence. pluginRegistry shares the same classifier. Migrate every plugin import site to one loadPlugin call per plugin module. Two call sites get a guard where code relied on the old all-or-nothing loading: the Summarize tab now requires its view, and PersistentLogin calls setSelectedProduct optionally. --- frontend/src/App.jsx | 15 ++- .../ConfigureConnectorModal.jsx | 17 +--- .../ds-settings-card/DsSettingsCard.jsx | 21 ++--- .../combined-output/CombinedOutput.jsx | 58 +++++------- .../custom-tools/combined-output/JsonView.jsx | 15 ++- .../document-manager/DocumentManager.jsx | 54 +++++------ .../document-parser/DocumentParser.jsx | 35 ++++--- .../components/custom-tools/header/Header.jsx | 33 +++---- .../manage-docs-modal/ManageDocsModal.jsx | 43 ++++----- .../output-analyzer/OutputAnalyzerCard.jsx | 13 +-- .../output-analyzer/OutputAnalyzerHeader.jsx | 13 +-- .../OutputForDocModal.jsx | 13 +-- .../custom-tools/pdf-viewer/PdfViewer.jsx | 13 ++- .../custom-tools/prompt-card/Header.jsx | 62 +++++------- .../custom-tools/prompt-card/PromptCard.jsx | 21 ++--- .../prompt-card/PromptCardItems.jsx | 41 ++++---- .../custom-tools/prompt-card/PromptOutput.jsx | 53 +++++------ .../prompt-card/PromptOutputsModal.jsx | 11 +-- .../settings-modal/SettingsModal.jsx | 36 ++++--- .../custom-tools/tool-ide/ToolIde.jsx | 88 ++++++++--------- .../tools-main/ToolsMainActionBtns.jsx | 41 ++++---- .../helpers/auth/PersistentLogin.jsx | 25 +++-- .../components/helpers/auth/RequireAuth.jsx | 26 ++--- .../components/helpers/auth/RequireGuest.jsx | 12 +-- .../custom-tools/CustomToolsHelper.jsx | 30 +++--- .../input-output/add-source/AddSource.jsx | 46 +++++---- frontend/src/components/log-in/Login.jsx | 11 +-- .../metrics-dashboard/MetricsDashboard.jsx | 31 +++--- .../navigations/side-nav-bar/SideNavBar.jsx | 88 ++++++----------- .../navigations/top-nav-bar/TopNavBar.jsx | 94 +++++++------------ .../settings/platform/PlatformSettings.jsx | 12 +-- frontend/src/helpers/GetStaticData.js | 14 +-- frontend/src/helpers/pluginLoader.js | 77 ++++++++++----- frontend/src/helpers/pluginLoader.test.js | 88 +++++++++++++++++ frontend/src/helpers/pluginRegistry.js | 15 +-- frontend/src/hooks/usePromptOutput.js | 58 +++++------- frontend/src/hooks/useSessionValid.js | 34 +++---- .../src/layouts/page-layout/PageLayout.jsx | 23 ++--- frontend/src/pages/CustomTools.jsx | 11 ++- .../src/pages/UnstractAdministrationPage.jsx | 16 ++-- frontend/src/routes/Router.jsx | 34 +++---- frontend/src/routes/useMainAppRoutes.jsx | 19 ++-- frontend/vitest.config.mjs | 4 +- 43 files changed, 660 insertions(+), 804 deletions(-) create mode 100644 frontend/src/helpers/pluginLoader.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 03fb338174..a5f80abd91 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import { NotificationClearAll } from "./components/notification/NotificationClea import { NotificationIdLine } from "./components/notification/NotificationIdLine.jsx"; import { PageTitle } from "./components/widgets/page-title/PageTitle.jsx"; import { THEME } from "./helpers/GetStaticData.js"; +import { loadPlugin } from "./helpers/pluginLoader.js"; import { attachRequestIdInterceptor } from "./helpers/requestId.js"; import PostHogPageviewTracker from "./PostHogPageviewTracker.js"; import { Router } from "./routes/Router.jsx"; @@ -26,15 +27,11 @@ if (!axios[GLOBAL_INTERCEPTOR_FLAG]) { axios[GLOBAL_INTERCEPTOR_FLAG] = true; } -let GoogleTagManagerHelper; -try { - const mod = await import( - "./plugins/google-tag-manager-helper/GoogleTagManagerHelper.js" - ); - GoogleTagManagerHelper = mod.GoogleTagManagerHelper; -} catch { - // The component will remain null of it is not available -} +const GoogleTagManagerHelper = await loadPlugin(() => + import("./plugins/google-tag-manager-helper/GoogleTagManagerHelper.js").then( + (m) => m.GoogleTagManagerHelper, + ), +); function App() { const { sessionDetails, isLogoutLoading } = useSessionStore(); diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx index 3df2ac526c..40477a1133 100644 --- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx +++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx @@ -11,6 +11,7 @@ import { Tabs } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; import { fetchAllPages } from "../../../helpers/pagination"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; @@ -24,18 +25,10 @@ import { ReadOnlyNotice } from "../../widgets/read-only-notice/ReadOnlyNotice"; import { ConfigureFormsLayout } from "../configure-forms-layout/ConfigureFormsLayout"; import "./ConfigureConnectorModal.css"; -let RuleEngine; -let ruleEngineTabs; - -try { - const ruleEnginePlugin = await import( - "../../../plugins/manual-review/rule-engine" - ); - RuleEngine = ruleEnginePlugin.RuleEngine; - ruleEngineTabs = ruleEnginePlugin.ruleEngineTabs; -} catch { - // Plugin not available -} +const { RuleEngine, ruleEngineTabs } = await loadPlugin( + () => import("../../../plugins/manual-review/rule-engine"), + {}, +); function ConfigureConnectorModal({ open, diff --git a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx index 2ca1c4d083..7f40ea176e 100644 --- a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx +++ b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx @@ -7,6 +7,7 @@ import { Image } from "@/components/ui/shims/antd-leaves"; import { Tooltip } from "@/components/ui/shims/antd-overlays"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import useRequestUrl from "../../../hooks/useRequestUrl"; @@ -67,19 +68,15 @@ function DsSettingsCard({ connType, endpointDetails, message }) { }; useEffect(() => { - const loadPlugin = async () => { - try { - const mod = await import( - "../../../plugins/dscard-input-options/AppDeploymentCardInputOptions" - ); - if (flags.app_deployment && mod.appDeploymentInputOption) { - setUpdatedInputoptions(mod.appDeploymentInputOption); - } - } catch { - // The component will remain null of it is not available + loadPlugin(() => + import( + "../../../plugins/dscard-input-options/AppDeploymentCardInputOptions" + ).then((m) => m.appDeploymentInputOption), + ).then((appDeploymentInputOption) => { + if (flags.app_deployment && appDeploymentInputOption) { + setUpdatedInputoptions(appDeploymentInputOption); } - }; - loadPlugin(); + }); }, []); useEffect(() => { diff --git a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx index 776b9eea59..cc385aa859 100644 --- a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx +++ b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx @@ -11,6 +11,7 @@ import { displayPromptResult, promptType, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useAlertStore } from "../../../store/alert-store"; import { useCustomToolStore } from "../../../store/custom-tool-store"; @@ -19,41 +20,34 @@ import "./CombinedOutput.css"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { JsonView } from "./JsonView"; -let TableView; -let promptOutputApiSps; -try { - const tvMod = await import("../../../plugins/simple-prompt-studio/TableView"); - TableView = tvMod.TableView; - const helperMod = await import( - "../../../plugins/simple-prompt-studio/helper" - ); - promptOutputApiSps = helperMod.promptOutputApiSps; -} catch { - // The component will remain null if it is not available -} +const TableView = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/TableView").then( + (m) => m.TableView, + ), +); +const promptOutputApiSps = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/helper").then( + (m) => m.promptOutputApiSps, + ), +); -let publicOutputsApi; -let publicDefaultOutputApi; -try { - const mod = await import( - "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicOutputsApi = mod.publicOutputsApi; - publicDefaultOutputApi = mod.publicDefaultOutputApi; -} catch { - // The component will remain null if it is not available -} +const { publicOutputsApi, publicDefaultOutputApi } = await loadPlugin( + () => + import( + "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" + ), + {}, +); // OSS falls back to passthrough helpers — no enrichment. -let splitCombinedData = (data) => ({ combined: data, bundle: null }); -let buildEnrichedFromBundle = (_output, _bundle, _formatter) => ({}); -let getEnrichmentFromItem = (_item) => null; -try { - const mod = await import("../../../plugins/lookup-enriched-toggle/helpers"); - splitCombinedData = mod.splitCombinedData; - buildEnrichedFromBundle = mod.buildEnrichedFromBundle; - getEnrichmentFromItem = mod.getEnrichmentFromItem; -} catch {} +const { + splitCombinedData = (data) => ({ combined: data, bundle: null }), + buildEnrichedFromBundle = (_output, _bundle, _formatter) => ({}), + getEnrichmentFromItem = (_item) => null, +} = await loadPlugin( + () => import("../../../plugins/lookup-enriched-toggle/helpers"), + {}, +); const buildDefaultProfileOutputs = (data) => { const { combined: payload, bundle } = splitCombinedData(data); diff --git a/frontend/src/components/custom-tools/combined-output/JsonView.jsx b/frontend/src/components/custom-tools/combined-output/JsonView.jsx index f05dfd2638..ce1ea3bb87 100644 --- a/frontend/src/components/custom-tools/combined-output/JsonView.jsx +++ b/frontend/src/components/custom-tools/combined-output/JsonView.jsx @@ -4,18 +4,15 @@ import { useEffect, useRef, useState } from "react"; import { Tooltip } from "@/components/ui/shims/antd-overlays"; import { Tabs } from "@/components/ui/shims/antd-structure"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { JsonViewBody } from "./JsonViewBody"; -let EnrichedOutputToggle; -try { - const mod = await import( - "../../../plugins/lookup-enriched-toggle/EnrichedOutputToggle" - ); - EnrichedOutputToggle = mod.EnrichedOutputToggle; -} catch { - // The component will remain undefined if it is not available -} +const EnrichedOutputToggle = await loadPlugin(() => + import("../../../plugins/lookup-enriched-toggle/EnrichedOutputToggle").then( + (m) => m.EnrichedOutputToggle, + ), +); function JsonView({ combinedOutput, diff --git a/frontend/src/components/custom-tools/document-manager/DocumentManager.jsx b/frontend/src/components/custom-tools/document-manager/DocumentManager.jsx index e1cc1b14d0..d30d2f6ef5 100644 --- a/frontend/src/components/custom-tools/document-manager/DocumentManager.jsx +++ b/frontend/src/components/custom-tools/document-manager/DocumentManager.jsx @@ -17,6 +17,7 @@ import { base64toBlobWithMime, docIndexStatus, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import { useCustomToolStore } from "../../../store/custom-tool-store"; @@ -44,41 +45,34 @@ const viewTypes = { }; // Import components for the summarize feature -let SummarizeView = null; -try { - const svMod = await import("../../../plugins/summarize-view/SummarizeView"); - SummarizeView = svMod.SummarizeView; - const stMod = await import("../../../plugins/summarize-tab/SummarizeTab"); - const tabLabel = stMod.tabLabel; - if (tabLabel) { - items.push({ - key: "3", - label: tabLabel, - }); - } -} catch { - // The component will remain null of it is not available +const SummarizeView = await loadPlugin(() => + import("../../../plugins/summarize-view/SummarizeView").then( + (m) => m.SummarizeView, + ), +); +const summarizeTabLabel = await loadPlugin(() => + import("../../../plugins/summarize-tab/SummarizeTab").then((m) => m.tabLabel), +); +// The tab is only useful when the view it opens is available too. +if (SummarizeView && summarizeTabLabel) { + items.push({ + key: "3", + label: summarizeTabLabel, + }); } // Import component for the simple prompt studio feature -let getDocumentsSps; -try { - const mod = await import( +const getDocumentsSps = await loadPlugin(() => + import( "../../../plugins/simple-prompt-studio/simple-prompt-studio-api-service" - ); - getDocumentsSps = mod.getDocumentsSps; -} catch { - // The component will remain null of it is not available -} -let publicDocumentApi; -try { - const mod = await import( + ).then((m) => m.getDocumentsSps), +); +const publicDocumentApi = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicDocumentApi = mod.publicDocumentApi; -} catch { - // The component will remain null of it is not available -} + ).then((m) => m.publicDocumentApi), +); + function DocumentManager({ generateIndex, handleUpdateTool, handleDocChange }) { const [openManageDocsModal, setOpenManageDocsModal] = useState(false); const [page, setPage] = useState(1); diff --git a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx index c7d9fd1004..f34d31335c 100644 --- a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx +++ b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx @@ -4,6 +4,7 @@ import { useSearchParams } from "react-router-dom"; import "./DocumentParser.css"; import { promptType } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; @@ -13,25 +14,21 @@ import { useSessionStore } from "../../../store/session-store"; import { EmptyState } from "../../widgets/empty-state/EmptyState"; import { PromptCardWrapper } from "../prompt-card/PromptCardWrapper"; -let promptCardService; -let promptPatchApiSps; -let SpsPromptsEmptyState; -try { - const pcMod = await import( - "../../../plugins/prompt-card/prompt-card-service" - ); - promptCardService = pcMod.promptCardService; - const helperMod = await import( - "../../../plugins/simple-prompt-studio/helper" - ); - promptPatchApiSps = helperMod.promptPatchApiSps; - const spsMod = await import( - "../../../plugins/simple-prompt-studio/SpsPromptsEmptyState" - ); - SpsPromptsEmptyState = spsMod.SpsPromptsEmptyState; -} catch { - // The component will remain null of it is not available -} +const promptCardService = await loadPlugin(() => + import("../../../plugins/prompt-card/prompt-card-service").then( + (m) => m.promptCardService, + ), +); +const promptPatchApiSps = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/helper").then( + (m) => m.promptPatchApiSps, + ), +); +const SpsPromptsEmptyState = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/SpsPromptsEmptyState").then( + (m) => m.SpsPromptsEmptyState, + ), +); // Module-scoped to avoid per-render recompilation. const UUID_RE = diff --git a/frontend/src/components/custom-tools/header/Header.jsx b/frontend/src/components/custom-tools/header/Header.jsx index bae4bbdc4a..174a0aea1e 100644 --- a/frontend/src/components/custom-tools/header/Header.jsx +++ b/frontend/src/components/custom-tools/header/Header.jsx @@ -6,6 +6,7 @@ import { Form } from "@/components/ui/shims/antd-form"; import { Input } from "@/components/ui/shims/antd-inputs"; import { Dropdown, Modal, Tooltip } from "@/components/ui/shims/antd-overlays"; import { ExportToolIcon } from "../../../assets"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; @@ -19,27 +20,21 @@ import { CustomButton } from "../../widgets/custom-button/CustomButton"; import { ExportTool } from "../export-tool/ExportTool"; import "./Header.css"; -let SinglePassToggleSwitch; -let CloneButton; -let PromptShareButton; -try { - const mod = await import( +const SinglePassToggleSwitch = await loadPlugin(() => + import( "../../../plugins/single-pass-toggle-switch/SinglePassToggleSwitch" - ); - SinglePassToggleSwitch = mod.SinglePassToggleSwitch; -} catch {} -try { - const mod = await import( + ).then((m) => m.SinglePassToggleSwitch), +); +const PromptShareButton = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/public-share-btn/PromptShareButton.jsx" - ); - PromptShareButton = mod.PromptShareButton; -} catch {} -try { - const mod = await import( - "../../../plugins/prompt-studio-clone/clone-btn/CloneButton.jsx" - ); - CloneButton = mod.CloneButton; -} catch {} + ).then((m) => m.PromptShareButton), +); +const CloneButton = await loadPlugin(() => + import("../../../plugins/prompt-studio-clone/clone-btn/CloneButton.jsx").then( + (m) => m.CloneButton, + ), +); const noopCheckLookups = () => Promise.resolve(true); diff --git a/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx b/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx index 5123867484..8f712aab75 100644 --- a/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx +++ b/frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx @@ -10,6 +10,7 @@ import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays"; import { Table, Upload } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; @@ -24,35 +25,23 @@ import "./ManageDocsModal.css"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import { usePromptOutputStore } from "../../../store/prompt-output-store"; -let SummarizeStatusTitle = null; -try { - const mod = await import( - "../../../plugins/summarize-status-title/SummarizeStatusTitle" - ); - SummarizeStatusTitle = mod.SummarizeStatusTitle; -} catch { - // The component will remain null if it is not available -} +const SummarizeStatusTitle = await loadPlugin(() => + import("../../../plugins/summarize-status-title/SummarizeStatusTitle").then( + (m) => m.SummarizeStatusTitle, + ), +); -let publicIndexApi = null; -try { - const mod = await import( +const publicIndexApi = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicIndexApi = mod.publicIndexApi; -} catch { - // The component will remain null if it is not available -} - -let ConfirmMultiDoc = null; -try { - const mod = await import( - "../../../plugins/prompt-studio-multi-doc/ConfirmMultiDoc" - ); - ConfirmMultiDoc = mod.ConfirmMultiDoc; -} catch { - // The component will remain null if it is not available -} + ).then((m) => m.publicIndexApi), +); + +const ConfirmMultiDoc = await loadPlugin(() => + import("../../../plugins/prompt-studio-multi-doc/ConfirmMultiDoc").then( + (m) => m.ConfirmMultiDoc, + ), +); const indexTypes = { raw: "RAW", diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx index 2625fbf2ea..3e3d4d99d4 100644 --- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx +++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx @@ -6,6 +6,7 @@ import { Divider } from "@/components/ui/shims/antd-leaves"; import { Typography } from "@/components/ui/shims/antd-typography"; import { base64toBlob } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; @@ -15,15 +16,11 @@ import { CombinedOutput } from "../combined-output/CombinedOutput"; import { DocumentViewer } from "../document-viewer/DocumentViewer"; import { PdfViewer } from "../pdf-viewer/PdfViewer"; -let publicDocumentApi; -try { - const mod = await import( +const publicDocumentApi = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicDocumentApi = mod.publicDocumentApi; -} catch { - // The component will remain null if it is not available -} + ).then((m) => m.publicDocumentApi), +); function OutputAnalyzerCard({ doc, selectedPrompts, totalFields }) { const [fileUrl, setFileUrl] = useState(""); diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx index 32ffab41f1..18027d8053 100644 --- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx +++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx @@ -12,18 +12,15 @@ import { Button } from "@/components/ui/shims/antd-button"; import { Space } from "@/components/ui/shims/antd-layout"; import { Drawer, Menu } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { useSessionStore } from "../../../store/session-store"; -let HeaderPublic; -try { - const mod = await import( +const HeaderPublic = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/header-public/HeaderPublic.jsx" - ); - HeaderPublic = mod.HeaderPublic; -} catch { - // Do nothing if plugins are not loaded. -} + ).then((m) => m.HeaderPublic), +); const PAGINATION_ACTIONS = { PREV: "PREV", diff --git a/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx b/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx index 37449e2dea..475e37f218 100644 --- a/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx +++ b/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx @@ -14,21 +14,18 @@ import { displayPromptResult, getDocIdFromKey, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; import { useTokenUsageStore } from "../../../store/token-usage-store"; import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; import { ProfileInfoBar } from "../profile-info-bar/ProfileInfoBar"; -let publicOutputsApi; -try { - const mod = await import( +const publicOutputsApi = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicOutputsApi = mod.publicOutputsApi; -} catch { - // The component will remain null of it is not available -} + ).then((m) => m.publicOutputsApi), +); const outputStatus = { yet_to_process: "YET_TO_PROCESS", diff --git a/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx b/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx index af3c349a11..78ba1d0cde 100644 --- a/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx +++ b/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx @@ -13,14 +13,13 @@ import "@react-pdf-viewer/default-layout/lib/styles/index.css"; import "@react-pdf-viewer/highlight/lib/styles/index.css"; import "./Highlight.css"; import { PDF_WORKER_URL } from "../../../helpers/pdfWorkerConfig"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; -let RenderHighlights; -try { - const mod = await import("../../../plugins/pdf-highlight/RenderHighlights"); - RenderHighlights = mod.RenderHighlights; -} catch { - // Do nothing, no plugin will be loaded. -} +const RenderHighlights = await loadPlugin(() => + import("../../../plugins/pdf-highlight/RenderHighlights").then( + (m) => m.RenderHighlights, + ), +); function PdfLoadError({ error, onRetry, reportError }) { useEffect(() => { diff --git a/frontend/src/components/custom-tools/prompt-card/Header.jsx b/frontend/src/components/custom-tools/prompt-card/Header.jsx index 7690c69844..739dfbb6d7 100644 --- a/frontend/src/components/custom-tools/prompt-card/Header.jsx +++ b/frontend/src/components/custom-tools/prompt-card/Header.jsx @@ -21,56 +21,42 @@ import { PROMPT_RUN_TYPES, promptStudioUpdateStatus, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal"; import { EditableText } from "../editable-text/EditableText"; import { ExpandCardBtn } from "./ExpandCardBtn"; -let PromptRunBtnSps; -try { - const mod = await import( - "../../../plugins/simple-prompt-studio/PromptRunBtnSps" - ); - PromptRunBtnSps = mod.PromptRunBtnSps; -} catch { - // The component will remain 'undefined' it is not available -} +const PromptRunBtnSps = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/PromptRunBtnSps").then( + (m) => m.PromptRunBtnSps, + ), +); -let PromptChangeIndicator; -try { - const mod = await import( - "../../../plugins/prompt-change-indicator/PromptChangeIndicator" - ); - PromptChangeIndicator = mod.PromptChangeIndicator; -} catch { - // Cloud-only feedback loop indicator; stays undefined in OSS builds -} +// Cloud-only feedback loop indicator; stays null in OSS builds +const PromptChangeIndicator = await loadPlugin(() => + import("../../../plugins/prompt-change-indicator/PromptChangeIndicator").then( + (m) => m.PromptChangeIndicator, + ), +); -let LookupMenuItem; -try { - const mod = await import( - "../../../plugins/lookup-studio/prompt-card/LookupMenuItem" - ); - LookupMenuItem = mod.LookupMenuItem; -} catch {} +const LookupMenuItem = await loadPlugin(() => + import("../../../plugins/lookup-studio/prompt-card/LookupMenuItem").then( + (m) => m.LookupMenuItem, + ), +); -let usePromptRunGatePlugin; -let lookupPluginLoadError; -try { - const mod = await import( - "../../../plugins/lookup-studio/hooks/usePromptRunGate" - ); - usePromptRunGatePlugin = mod.usePromptRunGate; -} catch (err) { - lookupPluginLoadError = err; -} +const usePromptRunGatePlugin = await loadPlugin(() => + import("../../../plugins/lookup-studio/hooks/usePromptRunGate").then( + (m) => m.usePromptRunGate, + ), +); // Sibling plugin loaded but this hook didn't — surface so the no-op fallback // doesn't silently disable the run gate. -if (lookupPluginLoadError && LookupMenuItem) { +if (!usePromptRunGatePlugin && LookupMenuItem) { console.warn( - "[Header] lookup-studio plugin loaded but usePromptRunGate failed to import", - lookupPluginLoadError, + "[Header] lookup-studio plugin loaded but usePromptRunGate is unavailable", ); } diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx index cbc9e9590f..f0aafddbd0 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx @@ -5,6 +5,7 @@ import { PROMPT_RUN_API_STATUSES, promptStudioUpdateStatus, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import { useAlertStore } from "../../../store/alert-store"; import { useCustomToolStore } from "../../../store/custom-tool-store"; @@ -14,18 +15,14 @@ import { PromptCardItems } from "./PromptCardItems"; import "./PromptCard.css"; import { handleUpdateStatus } from "./constants"; -let useEnforceTypeSwitchGatePlugin; -try { - const mod = await import( - "../../../plugins/lookup-studio/hooks/useEnforceTypeSwitchGate" - ); - useEnforceTypeSwitchGatePlugin = mod.useEnforceTypeSwitchGate; -} catch { - // Cloud plugin not present; gate falls back to no-op below. -} - -const useEnforceTypeSwitchGate = - useEnforceTypeSwitchGatePlugin || (() => () => null); +// Cloud plugin; without it the gate falls back to a no-op. +const useEnforceTypeSwitchGate = await loadPlugin( + () => + import( + "../../../plugins/lookup-studio/hooks/useEnforceTypeSwitchGate" + ).then((m) => m.useEnforceTypeSwitchGate), + () => () => null, +); // Fields with an inline error renderer on the card. Anything else falls back // to the global alert, so widening the sanitizer cannot silence a rejection. diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx index 699f68f709..0225eecb47 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx @@ -8,6 +8,7 @@ import { Divider, Tag } from "@/components/ui/shims/antd-leaves"; import { Collapse } from "@/components/ui/shims/antd-overlays"; import { Card } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; import { EditableText } from "../editable-text/EditableText"; @@ -15,33 +16,23 @@ import { Header } from "./Header"; import { OutputForIndex } from "./OutputForIndex"; import { PromptOutput } from "./PromptOutput"; -let TableExtractionSettingsBtn; -try { - const mod = await import( - "../../../plugins/prompt-card/TableExtractionSettingsBtn" - ); - TableExtractionSettingsBtn = mod.TableExtractionSettingsBtn; -} catch { - // The component will remain null of it is not available -} +const TableExtractionSettingsBtn = await loadPlugin(() => + import("../../../plugins/prompt-card/TableExtractionSettingsBtn").then( + (m) => m.TableExtractionSettingsBtn, + ), +); -let LookupIndicator; -try { - const mod = await import( - "../../../plugins/lookup-studio/prompt-card/LookupIndicator" - ); - LookupIndicator = mod.LookupIndicator; -} catch {} +const LookupIndicator = await loadPlugin(() => + import("../../../plugins/lookup-studio/prompt-card/LookupIndicator").then( + (m) => m.LookupIndicator, + ), +); -let AgenticTableChecklist; -try { - const mod = await import( - "../../../plugins/prompt-card/AgenticTableChecklist" - ); - AgenticTableChecklist = mod.AgenticTableChecklist; -} catch { - // The component will remain null of it is not available -} +const AgenticTableChecklist = await loadPlugin(() => + import("../../../plugins/prompt-card/AgenticTableChecklist").then( + (m) => m.AgenticTableChecklist, + ), +); function PromptCardItems({ promptDetails, diff --git a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx index 738e1832b4..fbc9ee7b7c 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx @@ -15,6 +15,7 @@ import { PROMPT_RUN_API_STATUSES, PROMPT_RUN_TYPES, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import usePromptOutput from "../../../hooks/usePromptOutput"; import { useWindowDimensions } from "../../../hooks/useWindowDimensions"; import { useAlertStore } from "../../../store/alert-store"; @@ -28,40 +29,28 @@ import { PromptOutputExpandBtn } from "./PromptOutputExpandBtn"; import { PromptRunCost } from "./PromptRunCost"; import { PromptRunTimer } from "./PromptRunTimer"; -let TableOutput; -try { - const mod = await import("../../../plugins/prompt-card/TableOutput"); - TableOutput = mod.TableOutput; -} catch { - // The component will remain null of it is not available -} -let ChallengeModal; -try { - const mod = await import("../../../plugins/challenge-modal/ChallengeModal"); - ChallengeModal = mod.ChallengeModal; -} catch { - // The component will remain null of it is not available -} +const TableOutput = await loadPlugin(() => + import("../../../plugins/prompt-card/TableOutput").then((m) => m.TableOutput), +); +const ChallengeModal = await loadPlugin(() => + import("../../../plugins/challenge-modal/ChallengeModal").then( + (m) => m.ChallengeModal, + ), +); -let LookupOutputTabs; -try { - const mod = await import( - "../../../plugins/lookup-studio/prompt-card/LookupOutputTabs" - ); - LookupOutputTabs = mod.LookupOutputTabs; -} catch { - // Cloud-only plugin — absent in OSS builds; LookupOutputTabs stays null. -} +// Cloud-only plugin — absent in OSS builds; LookupOutputTabs stays null. +const LookupOutputTabs = await loadPlugin(() => + import("../../../plugins/lookup-studio/prompt-card/LookupOutputTabs").then( + (m) => m.LookupOutputTabs, + ), +); -let getEnrichedCopyText; -try { - const mod = await import( - "../../../plugins/lookup-studio/prompt-card/getEnrichedCopyText" - ); - getEnrichedCopyText = mod.getEnrichedCopyText; -} catch { - // Cloud-only plugin — absent in OSS builds; falls back to raw copy text. -} +// Cloud-only plugin — absent in OSS builds; falls back to raw copy text. +const getEnrichedCopyText = await loadPlugin(() => + import("../../../plugins/lookup-studio/prompt-card/getEnrichedCopyText").then( + (m) => m.getEnrichedCopyText, + ), +); // Fallback to raw text — plugin throw on malformed enrichment shouldn't break Copy. const resolveCopyText = (promptOutputId, fallbackText) => { diff --git a/frontend/src/components/custom-tools/prompt-card/PromptOutputsModal.jsx b/frontend/src/components/custom-tools/prompt-card/PromptOutputsModal.jsx index ad579d125c..53539ee58f 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptOutputsModal.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptOutputsModal.jsx @@ -2,6 +2,7 @@ import PropTypes from "prop-types"; import { Col, Row } from "@/components/ui/shims/antd-layout"; import { Modal } from "@/components/ui/shims/antd-overlays"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import usePromptOutput from "../../../hooks/usePromptOutput"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper"; @@ -9,13 +10,9 @@ import { TABLE } from "./constants"; import { DisplayPromptResult } from "./DisplayPromptResult"; import { ProfileIcon } from "./ProfileIcon"; -let TableOutput; -try { - const mod = await import("../../../plugins/prompt-card/TableOutput"); - TableOutput = mod.TableOutput; -} catch { - // The component will remain null of it is not available -} +const TableOutput = await loadPlugin(() => + import("../../../plugins/prompt-card/TableOutput").then((m) => m.TableOutput), +); function PromptOutputsModal({ open, diff --git a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx index 99a68d5fb9..1f78615e9c 100644 --- a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx +++ b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx @@ -12,6 +12,7 @@ import { Modal } from "@/components/ui/shims/antd-overlays"; import { Menu } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; import { getMenuItem } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper"; import { CustomDataSettings } from "../custom-data-settings/CustomDataSettings"; import { CustomSynonyms } from "../custom-synonyms/CustomSynonyms"; @@ -20,26 +21,23 @@ import { PreAndPostAmbleModal } from "../pre-and-post-amble-modal/PreAndPostAmbl import "./SettingsModal.css"; -let SummarizeManager = null; +const SummarizeManager = await loadPlugin(() => + import("../../../plugins/summarize-manager/SummarizeManager").then( + (m) => m.SummarizeManager, + ), +); const EvaluationManager = null; -let ChallengeManager = null; -let HighlightManager = null; -try { - const smMod = await import( - "../../../plugins/summarize-manager/SummarizeManager" - ); - SummarizeManager = smMod.SummarizeManager; - const cmMod = await import( - "../../../plugins/challenge-manager/ChallengeManager" - ); - ChallengeManager = cmMod.ChallengeManager; - const hmMod = await import( - "../../../plugins/highlight-manager/HighlightManager" - ); - HighlightManager = hmMod.HighlightManager; -} catch { - // Component will remain null if it is not present. -} +const ChallengeManager = await loadPlugin(() => + import("../../../plugins/challenge-manager/ChallengeManager").then( + (m) => m.ChallengeManager, + ), +); +const HighlightManager = await loadPlugin(() => + import("../../../plugins/highlight-manager/HighlightManager").then( + (m) => m.HighlightManager, + ), +); + function SettingsModal({ open, setOpen, handleUpdateTool }) { const [selectedId, setSelectedId] = useState(1); const [menuItems, setMenuItems] = useState([]); diff --git a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx index 86a6147ec4..17a6025ec1 100644 --- a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx +++ b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Col, Row } from "@/components/ui/shims/antd-layout"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; @@ -15,69 +16,58 @@ import "./ToolIde.css"; import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import { PageTitle } from "../../widgets/page-title/PageTitle.jsx"; -let PromptShareModal; -let PromptShareLink; -let CloneTitle; -let HeaderPublic; - -try { - const shareMod = await import( +const PromptShareModal = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/public-share-modal/PromptShareModal.jsx" - ); - PromptShareModal = shareMod.PromptShareModal; - const linkMod = await import( + ).then((m) => m.PromptShareModal), +); +const PromptShareLink = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/public-link-modal/PromptShareLink.jsx" - ); - PromptShareLink = linkMod.PromptShareLink; - const headerMod = await import( + ).then((m) => m.PromptShareLink), +); +const HeaderPublic = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/header-public/HeaderPublic.jsx" - ); - HeaderPublic = headerMod.HeaderPublic; -} catch { - // Do nothing if plugins are not loaded. -} -try { - const mod = await import( + ).then((m) => m.HeaderPublic), +); +const CloneTitle = await loadPlugin(() => + import( "../../../plugins/prompt-studio-clone/clone-title-modal/CloneTitle.jsx" - ); - CloneTitle = mod.CloneTitle; -} catch { - // Do nothing if plugins are not loaded. -} + ).then((m) => m.CloneTitle), +); // Cloud-only — OSS stub. -let useLookupDirtySeed = () => {}; -try { - const mod = await import( - "../../../plugins/lookup-studio/hooks/useLookupDirtySeed.js" - ); - useLookupDirtySeed = mod.useLookupDirtySeed; -} catch {} +const useLookupDirtySeed = await loadPlugin( + () => + import("../../../plugins/lookup-studio/hooks/useLookupDirtySeed.js").then( + (m) => m.useLookupDirtySeed, + ), + () => {}, +); // Cloud-only — OSS stub resolves true to skip the gate. -let useLookupExportGate = () => ({ - checkLookups: () => Promise.resolve(true), - modalEl: null, -}); -try { - const mod = await import( - "../../../plugins/lookup-studio/hooks/useLookupExportGate" - ); - useLookupExportGate = mod.useLookupExportGate; -} catch {} +const useLookupExportGate = await loadPlugin( + () => + import("../../../plugins/lookup-studio/hooks/useLookupExportGate").then( + (m) => m.useLookupExportGate, + ), + () => ({ + checkLookups: () => Promise.resolve(true), + modalEl: null, + }), +); /* * Cloud-only. Mounted here rather than beside either of its triggers: the * kebab menu unmounts its contents on click and the prompt card body unmounts * on collapse, so a drawer rendered in either was destroyed as it opened. */ -let LookupDrawerHost; -try { - const mod = await import( - "../../../plugins/lookup-studio/prompt-card/LookupDrawerHost" - ); - LookupDrawerHost = mod.LookupDrawerHost; -} catch {} +const LookupDrawerHost = await loadPlugin(() => + import("../../../plugins/lookup-studio/prompt-card/LookupDrawerHost").then( + (m) => m.LookupDrawerHost, + ), +); function ToolIde() { const [openSettings, setOpenSettings] = useState(false); diff --git a/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx b/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx index ba60b90c8f..e6c4a1c000 100644 --- a/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx +++ b/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { Button } from "@/components/ui/shims/antd-button"; import { Space } from "@/components/ui/shims/antd-layout"; import { Tooltip } from "@/components/ui/shims/antd-overlays"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; @@ -15,34 +16,24 @@ import { RunAllPrompts } from "../prompt-card/RunAllPrompts"; import { PromptsReorderModal } from "../prompts-reorder/PromptsReorderModal"; // Import single pass related components -let RunSinglePassBtn; -try { - const mod = await import( - "../../../plugins/run-single-pass-btn/RunSinglePassBtn" - ); - RunSinglePassBtn = mod.RunSinglePassBtn; -} catch { - // The variable will remain undefined if the component is not available -} +const RunSinglePassBtn = await loadPlugin(() => + import("../../../plugins/run-single-pass-btn/RunSinglePassBtn").then( + (m) => m.RunSinglePassBtn, + ), +); // Import simple prompt studio related components -let AddPromptBtn; -try { - const mod = await import( - "../../../plugins/simple-prompt-studio/AddPromptBtn" - ); - AddPromptBtn = mod.AddPromptBtn; -} catch { - // The variable will remain undefined if the component is not available -} +const AddPromptBtn = await loadPlugin(() => + import("../../../plugins/simple-prompt-studio/AddPromptBtn").then( + (m) => m.AddPromptBtn, + ), +); -let ChallengeModal; -try { - const mod = await import("../../../plugins/challenge-modal/ChallengeModal"); - ChallengeModal = mod.ChallengeModal; -} catch { - // The component will remain undefined if it is not available -} +const ChallengeModal = await loadPlugin(() => + import("../../../plugins/challenge-modal/ChallengeModal").then( + (m) => m.ChallengeModal, + ), +); function ToolsMainActionBtns() { const [openReorderModal, setOpenReorderModal] = useState(false); diff --git a/frontend/src/components/helpers/auth/PersistentLogin.jsx b/frontend/src/components/helpers/auth/PersistentLogin.jsx index ab0737ad46..fe695418cf 100644 --- a/frontend/src/components/helpers/auth/PersistentLogin.jsx +++ b/frontend/src/components/helpers/auth/PersistentLogin.jsx @@ -1,27 +1,23 @@ import { useEffect, useState } from "react"; import { Outlet, useLocation } from "react-router-dom"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import useSessionValid from "../../../hooks/useSessionValid"; import { useSessionStore } from "../../../store/session-store"; import { PromptRun } from "../../custom-tools/prompt-card/PromptRun"; import { GenericLoader } from "../../generic-loader/GenericLoader"; import { SocketMessages } from "../socket-messages/SocketMessages"; -let selectedProductStore; +const selectedProductStore = await loadPlugin( + () => import("../../../plugins/store/select-product-store.js"), +); let selectedProduct; let setSelectedProduct; -let SELECTED_PRODUCT; -let PRODUCT_NAMES = {}; -try { - selectedProductStore = await import( - "../../../plugins/store/select-product-store.js" - ); - const commonMod = await import("../../../plugins/helpers/common"); - SELECTED_PRODUCT = commonMod.SELECTED_PRODUCT; - PRODUCT_NAMES = commonMod.PRODUCT_NAMES ?? {}; -} catch { - // Ignore if hook not available -} + +const { SELECTED_PRODUCT, PRODUCT_NAMES = {} } = await loadPlugin( + () => import("../../../plugins/helpers/common"), + {}, +); function PersistentLogin() { const [isLoading, setIsLoading] = useState(true); @@ -70,7 +66,8 @@ function PersistentLogin() { selectedProductQueryParam && Object.values(PRODUCT_NAMES).includes(selectedProductQueryParam) ) { - setSelectedProduct(selectedProductQueryParam); + // The store and the product constants load independently now. + setSelectedProduct?.(selectedProductQueryParam); } }, [selectedProductQueryParam]); diff --git a/frontend/src/components/helpers/auth/RequireAuth.jsx b/frontend/src/components/helpers/auth/RequireAuth.jsx index a856c2e03d..0e0e7004af 100644 --- a/frontend/src/components/helpers/auth/RequireAuth.jsx +++ b/frontend/src/components/helpers/auth/RequireAuth.jsx @@ -6,28 +6,20 @@ import { homePagePath, onboardCompleted, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import { useSessionStore } from "../../../store/session-store"; -let ProductFruitsManager; -try { - const mod = await import( - "../../../plugins/product-fruits/ProductFruitsManager" - ); - ProductFruitsManager = mod.ProductFruitsManager; -} catch { - // The component will remain null of it is not available -} -let selectedProductStore; +const ProductFruitsManager = await loadPlugin(() => + import("../../../plugins/product-fruits/ProductFruitsManager").then( + (m) => m.ProductFruitsManager, + ), +); +const selectedProductStore = await loadPlugin( + () => import("../../../plugins/store/select-product-store.js"), +); let isLlmWhisperer; let isVerticals; -try { - selectedProductStore = await import( - "../../../plugins/store/select-product-store.js" - ); -} catch { - // do nothing -} const RequireAuth = () => { const { sessionDetails } = useSessionStore(); diff --git a/frontend/src/components/helpers/auth/RequireGuest.jsx b/frontend/src/components/helpers/auth/RequireGuest.jsx index f0cec7ef7c..90f283b88a 100644 --- a/frontend/src/components/helpers/auth/RequireGuest.jsx +++ b/frontend/src/components/helpers/auth/RequireGuest.jsx @@ -5,18 +5,14 @@ import { onboardCompleted, publicRoutes, } from "../../../helpers/GetStaticData"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useSessionStore } from "../../../store/session-store"; -let selectedProductStore; +const selectedProductStore = await loadPlugin( + () => import("../../../plugins/store/select-product-store.js"), +); let isLlmWhisperer; let isVerticals; -try { - selectedProductStore = await import( - "../../../plugins/store/select-product-store.js" - ); -} catch { - // do nothing -} const RequireGuest = () => { const { sessionDetails } = useSessionStore(); diff --git a/frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx b/frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx index 5a9dcb7574..386b2ab8ff 100644 --- a/frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx +++ b/frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Outlet, useNavigate, useParams } from "react-router-dom"; import { fetchAllPages } from "../../../helpers/pagination"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useAlertStore } from "../../../store/alert-store"; @@ -11,27 +12,20 @@ import { useSocketCustomToolStore } from "../../../store/socket-custom-tool"; import { useTokenUsageStore } from "../../../store/token-usage-store"; import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader"; -let shareManagerToolSource; -try { - const mod = await import( +// Without the plugin, the Not-found Page will be triggered. +const shareManagerToolSource = await loadPlugin(() => + import( "../../../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" + ).then((m) => m.shareManagerToolSource), +); + +const { fetchLookupAssignments, fetchLookupOutputs, resetLookupAssignments } = + await loadPlugin( + () => + import("../../../plugins/lookup-studio/store/useFetchLookupAssignments"), + {}, ); - shareManagerToolSource = mod.shareManagerToolSource; -} catch { - // Do nothing, Not-found Page will be triggered. -} -let fetchLookupAssignments; -let fetchLookupOutputs; -let resetLookupAssignments; -try { - const mod = await import( - "../../../plugins/lookup-studio/store/useFetchLookupAssignments" - ); - fetchLookupAssignments = mod.fetchLookupAssignments; - fetchLookupOutputs = mod.fetchLookupOutputs; - resetLookupAssignments = mod.resetLookupAssignments; -} catch {} function CustomToolsHelper() { const [isLoading, setIsLoading] = useState(true); const { id } = useParams(); diff --git a/frontend/src/components/input-output/add-source/AddSource.jsx b/frontend/src/components/input-output/add-source/AddSource.jsx index 60b1585f81..6bd29e943f 100644 --- a/frontend/src/components/input-output/add-source/AddSource.jsx +++ b/frontend/src/components/input-output/add-source/AddSource.jsx @@ -4,6 +4,7 @@ import PropTypes from "prop-types"; import { useEffect, useMemo, useState } from "react"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import useRequestUrl from "../../../hooks/useRequestUrl"; @@ -11,30 +12,27 @@ import { useAlertStore } from "../../../store/alert-store"; import { EmptyState } from "../../widgets/empty-state/EmptyState"; import { ConfigureDs } from "../configure-ds/ConfigureDs"; -let transformLlmWhispererJsonSchema; -let LLMW_V2_ID; -let PLAN_TYPES; -let unstractSubscriptionPlanStore; -let llmWhipererAdapterSchema; -try { - const schemaMod = await import( - "../../../plugins/unstract-subscription/helper/transformLlmWhispererJsonSchema" - ); - transformLlmWhispererJsonSchema = schemaMod.transformLlmWhispererJsonSchema; - LLMW_V2_ID = schemaMod.LLMW_V2_ID; - const constantsMod = await import( - "../../../plugins/unstract-subscription/helper/constants" - ); - PLAN_TYPES = constantsMod.PLAN_TYPES; - unstractSubscriptionPlanStore = await import( - "../../../plugins/store/unstract-subscription-plan-store" - ); - llmWhipererAdapterSchema = await import( - "../../../plugins/unstract-subscription/hooks/useLlmWhispererAdapterSchema.js" - ); -} catch { - // Ignore if not available -} +const { transformLlmWhispererJsonSchema, LLMW_V2_ID } = await loadPlugin( + () => + import( + "../../../plugins/unstract-subscription/helper/transformLlmWhispererJsonSchema" + ), + {}, +); +const PLAN_TYPES = await loadPlugin(() => + import("../../../plugins/unstract-subscription/helper/constants").then( + (m) => m.PLAN_TYPES, + ), +); +const unstractSubscriptionPlanStore = await loadPlugin( + () => import("../../../plugins/store/unstract-subscription-plan-store"), +); +const llmWhipererAdapterSchema = await loadPlugin( + () => + import( + "../../../plugins/unstract-subscription/hooks/useLlmWhispererAdapterSchema.js" + ), +); function AddSource({ selectedSourceId, diff --git a/frontend/src/components/log-in/Login.jsx b/frontend/src/components/log-in/Login.jsx index 54b55bb83d..7c22111440 100644 --- a/frontend/src/components/log-in/Login.jsx +++ b/frontend/src/components/log-in/Login.jsx @@ -2,17 +2,14 @@ import { Button } from "@/components/ui/shims/antd-button"; import { Col, Row } from "@/components/ui/shims/antd-layout"; import { getBaseUrl } from "../../helpers/GetStaticData"; +import { loadPlugin } from "../../helpers/pluginLoader.js"; import "./Login.css"; import { UnstractBlackLogo } from "../../assets"; import { ProductContentLayout } from "./ProductContentLayout"; -let LoginForm = null; -try { - const mod = await import("../../plugins/login-form/LoginForm"); - LoginForm = mod.LoginForm; -} catch { - // Plugin not available (OSS version) -} +const LoginForm = await loadPlugin(() => + import("../../plugins/login-form/LoginForm").then((m) => m.LoginForm), +); function Login() { const baseUrl = getBaseUrl(); diff --git a/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx b/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx index 3e814bf027..2dfda2a613 100644 --- a/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx +++ b/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx @@ -19,6 +19,7 @@ import { Typography } from "@/components/ui/shims/antd-typography"; import { EmptyPlaceholder } from "../../assets"; import { evictExpiredCache } from "../../helpers/metricsCache"; +import { loadPlugin } from "../../helpers/pluginLoader.js"; import { useMetricsOverview, useRecentActivity, @@ -33,27 +34,19 @@ import { RecentActivity } from "./RecentActivity"; import "./MetricsDashboard.css"; -// Cloud-only: Plan banner with subscription details -let PlanBanner; -try { - const mod = await import( - "../../plugins/unstract-subscription/components/PlanBanner.jsx" - ); - PlanBanner = mod.PlanBanner; -} catch { - // Plugin unavailable - no banner on OSS -} +// Cloud-only: Plan banner with subscription details (no banner on OSS) +const PlanBanner = await loadPlugin(() => + import("../../plugins/unstract-subscription/components/PlanBanner.jsx").then( + (m) => m.PlanBanner, + ), +); -// Cloud-only: Subscription usage tab -let SubscriptionUsageTab; -try { - const mod = await import( +// Cloud-only: Subscription usage tab (no subscription tab on OSS) +const SubscriptionUsageTab = await loadPlugin(() => + import( "../../plugins/unstract-subscription/components/SubscriptionUsageTab.jsx" - ); - SubscriptionUsageTab = mod.SubscriptionUsageTab; -} catch { - // Plugin unavailable - no subscription tab on OSS -} + ).then((m) => m.SubscriptionUsageTab), +); const { RangePicker } = DatePicker; diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx index cfa789c94d..f5dc4915a9 100644 --- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx +++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx @@ -25,6 +25,7 @@ import { getLocalStorageValue, setLocalStorageValue, } from "../../../helpers/localStorage"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useSessionStore } from "../../../store/session-store"; import "./SideNavBar.css"; @@ -32,67 +33,40 @@ import "../../settings/settings/Settings.css"; const { Sider } = Layout; -let getMenuItem; -try { - getMenuItem = await import("../../../plugins/app-deployment/getMenuItem"); -} catch { - // Plugin unavailable. -} +const getMenuItem = await loadPlugin( + () => import("../../../plugins/app-deployment/getMenuItem"), +); -let sideMenu; -try { - sideMenu = await import("../../../plugins/hooks/useSideMenu"); -} catch { - // Plugin unavailable. -} +const sideMenu = await loadPlugin( + () => import("../../../plugins/hooks/useSideMenu"), +); let unstractSubscriptionPlan; -let unstractSubscriptionPlanStore; -let UNSTRACT_SUBSCRIPTION_PLANS; -try { - unstractSubscriptionPlanStore = await import( - "../../../plugins/store/unstract-subscription-plan-store" - ); - const unstractSubscriptionConstants = await import( - "../../../plugins/unstract-subscription/helper/constants" - ); - UNSTRACT_SUBSCRIPTION_PLANS = - unstractSubscriptionConstants?.UNSTRACT_SUBSCRIPTION_PLANS; -} catch { - // Plugin unavailable. -} - -let selectedProductStore; +const unstractSubscriptionPlanStore = await loadPlugin( + () => import("../../../plugins/store/unstract-subscription-plan-store"), +); +const UNSTRACT_SUBSCRIPTION_PLANS = await loadPlugin(() => + import("../../../plugins/unstract-subscription/helper/constants").then( + (m) => m.UNSTRACT_SUBSCRIPTION_PLANS, + ), +); + +const selectedProductStore = await loadPlugin( + () => import("../../../plugins/store/select-product-store.js"), +); let selectedProduct; -try { - selectedProductStore = await import( - "../../../plugins/store/select-product-store.js" - ); -} catch { - // Ignore if hook not available -} - -let agenticPromptStudioEnabled = false; -try { - await import("../../../plugins/agentic-prompt-studio"); - agenticPromptStudioEnabled = true; -} catch { - // Plugin unavailable -} - -let lookupStudioEnabled = false; -try { - await import("../../../plugins/lookup-studio"); - lookupStudioEnabled = true; -} catch {} - -let manualReviewSettingsEnabled = false; -try { - await import("../../../plugins/manual-review/settings/Settings.jsx"); - manualReviewSettingsEnabled = true; -} catch { - // Plugin unavailable -} + +const agenticPromptStudioEnabled = + (await loadPlugin(() => import("../../../plugins/agentic-prompt-studio"))) !== + null; + +const lookupStudioEnabled = + (await loadPlugin(() => import("../../../plugins/lookup-studio"))) !== null; + +const manualReviewSettingsEnabled = + (await loadPlugin( + () => import("../../../plugins/manual-review/settings/Settings.jsx"), + )) !== null; const getSettingsMenuItems = (orgName, isAdmin) => [ { diff --git a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx index 98fdbdd635..469c674dab 100644 --- a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx +++ b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx @@ -23,6 +23,7 @@ import { onboardCompleted, THEME, } from "../../../helpers/GetStaticData.js"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import useLogout from "../../../hooks/useLogout.js"; import "../../../layouts/page-layout/PageLayout.css"; import { useSessionStore } from "../../../store/session-store.js"; @@ -32,46 +33,27 @@ import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useAlertStore } from "../../../store/alert-store.js"; import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal.jsx"; -let TrialDaysInfo; -try { - const mod = await import( +const TrialDaysInfo = await loadPlugin(() => + import( "../../../plugins/unstract-subscription/components/TrialDaysInfo.jsx" - ); - TrialDaysInfo = mod.default; -} catch { - // Plugin not found -} + ).then((m) => m.default), +); -let selectedProductStore; +const selectedProductStore = await loadPlugin( + () => import("../../../plugins/store/select-product-store.js"), +); let selectedProduct; -try { - selectedProductStore = await import( - "../../../plugins/store/select-product-store.js" - ); -} catch { - // Ignore if hook not available -} +const PlatformDropdown = await loadPlugin(() => + import("../../../plugins/platform-dropdown/PlatformDropDown.jsx").then( + (m) => m.PlatformDropdown, + ), +); -let PlatformDropdown; -try { - const mod = await import( - "../../../plugins/platform-dropdown/PlatformDropDown.jsx" - ); - PlatformDropdown = mod.PlatformDropdown; -} catch { - // Plugin not found -} - -let WhispererLogo; -let WhispererDarkLogo; -try { - const mod = await import("../../../plugins/assets/llmWhisperer/index.js"); - WhispererLogo = mod.WhispererLogo; - WhispererDarkLogo = mod.LlmWhispererLogo; -} catch { - // Ignore if hook not available -} +const { WhispererLogo, LlmWhispererLogo: WhispererDarkLogo } = await loadPlugin( + () => import("../../../plugins/assets/llmWhisperer/index.js"), + {}, +); /* * `Logo` is injected rather than hardcoded: the top bar is a light surface in @@ -110,35 +92,25 @@ const CustomLogo = ({ onClick, className, Logo }) => { // `APIHubLogo`/`WhispererLogo` are the WHITE-ink marks (named for the dark bar // they were drawn for); `APIHubDarkLogo`/`LlmWhispererLogo` are the dark-ink // ones. Both are pulled so the bar can pick by theme. -let APIHubLogo; -let APIHubDarkLogo; -try { - const mod = await import("../../../plugins/assets/verticals/index.js"); - APIHubLogo = mod.APIHubLogo; - APIHubDarkLogo = mod.APIHubDarkLogo; -} catch { - // Ignore if hook not available -} +const { APIHubLogo, APIHubDarkLogo } = await loadPlugin( + () => import("../../../plugins/assets/verticals/index.js"), + {}, +); let unstractSubscriptionPlan; -let unstractSubscriptionPlanStore; -let UNSTRACT_SUBSCRIPTION_PLANS; -let UnstractPricingMenuLink; -try { - unstractSubscriptionPlanStore = await import( - "../../../plugins/store/unstract-subscription-plan-store" - ); - const constantsMod = await import( - "../../../plugins/unstract-subscription/helper/constants" - ); - UNSTRACT_SUBSCRIPTION_PLANS = constantsMod.UNSTRACT_SUBSCRIPTION_PLANS; - const menuMod = await import( +const unstractSubscriptionPlanStore = await loadPlugin( + () => import("../../../plugins/store/unstract-subscription-plan-store"), +); +const UNSTRACT_SUBSCRIPTION_PLANS = await loadPlugin(() => + import("../../../plugins/unstract-subscription/helper/constants").then( + (m) => m.UNSTRACT_SUBSCRIPTION_PLANS, + ), +); +const UnstractPricingMenuLink = await loadPlugin(() => + import( "../../../plugins/unstract-subscription/components/UnstractPricingMenuLink.jsx" - ); - UnstractPricingMenuLink = menuMod.UnstractPricingMenuLink; -} catch { - // Plugin unavailable. -} + ).then((m) => m.UnstractPricingMenuLink), +); function TopNavBar({ isSimpleLayout, topNavBarOptions }) { const navigate = useNavigate(); diff --git a/frontend/src/components/settings/platform/PlatformSettings.jsx b/frontend/src/components/settings/platform/PlatformSettings.jsx index 51131c115f..c8eab55335 100644 --- a/frontend/src/components/settings/platform/PlatformSettings.jsx +++ b/frontend/src/components/settings/platform/PlatformSettings.jsx @@ -8,6 +8,7 @@ import { Col, Row } from "@/components/ui/shims/antd-layout"; import { Divider, Tag } from "@/components/ui/shims/antd-leaves"; import { Typography } from "@/components/ui/shims/antd-typography"; +import { loadPlugin } from "../../../helpers/pluginLoader.js"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; import { useAlertStore } from "../../../store/alert-store"; @@ -24,13 +25,10 @@ import { SettingsLayout } from "../settings-layout/SettingsLayout.jsx"; // there. Detect the enterprise build by probing for an enterprise-only plugin // (absent in OSS) and hide the control entirely otherwise. Mirrors the // plugin-gating idiom used in SideNavBar. -let isEnterpriseBuild = false; -try { - await import("../../../plugins/store/unstract-subscription-plan-store"); - isEnterpriseBuild = true; -} catch { - // OSS build — enterprise plugins are not bundled. -} +const isEnterpriseBuild = + (await loadPlugin( + () => import("../../../plugins/store/unstract-subscription-plan-store"), + )) !== null; const defaultKeys = [ { diff --git a/frontend/src/helpers/GetStaticData.js b/frontend/src/helpers/GetStaticData.js index 4a0b5b28dc..bbb8a859d0 100644 --- a/frontend/src/helpers/GetStaticData.js +++ b/frontend/src/helpers/GetStaticData.js @@ -3,13 +3,13 @@ import moment from "moment"; import momentTz from "moment-timezone"; import { v4 as uuidv4 } from "uuid"; -let cloudHomePagePath; -try { - const mod = await import("../plugins/unstract-subscription/helper/constants"); - cloudHomePagePath = mod.cloudHomePagePath; -} catch { - // Ignore if plugin not available -} +import { loadPlugin } from "./pluginLoader.js"; + +const cloudHomePagePath = await loadPlugin(() => + import("../plugins/unstract-subscription/helper/constants").then( + (m) => m.cloudHomePagePath, + ), +); const THEME = { DARK: "dark", diff --git a/frontend/src/helpers/pluginLoader.js b/frontend/src/helpers/pluginLoader.js index eefb192266..ae040f71b0 100644 --- a/frontend/src/helpers/pluginLoader.js +++ b/frontend/src/helpers/pluginLoader.js @@ -1,33 +1,60 @@ -// Classify a dynamic plugin-import failure as "plugin not shipped" -// (expected in OSS builds) vs. a real load failure. +// The one way to load an optional enterprise plugin that may be absent (OSS +// builds ship without `src/plugins/`). Route elements use `lazyPlugin` +// (pluginRegistry.js); everything else — components, hooks, stores, helpers, +// constants — goes through `loadPlugin`. // -// The PRIMARY signal in the shipped Vite bundle: vite.config.js's -// `optionalPluginImports` plugin resolves a missing optional-plugin path -// to a stub module whose body is `throw new Error('Optional plugin not -// available')`. That is the actual error an absent plugin throws in -// production, so it must be matched here or every OSS page load misfires -// the consumer's error branch. +// Zero-dependency on purpose: consumers include modules that import each +// other (Router/useMainAppRoutes/PageLayout), and this helper must never +// re-introduce an import cycle. + +// The only error that means "this plugin was not shipped" is the build-time +// stub vite.config.js's `optionalPluginImports` resolves a missing optional +// plugin to: `throw new Error('Optional plugin not available')`. +// `MODULE_NOT_FOUND` / "Cannot find module" are the Node equivalents, kept for +// anything that loads these modules outside Vite. // -// LIMITATION: this still isn't fully sound — a present-but-failed-to-load -// chunk (transient CDN/origin 5xx, a stale hashed asset) surfaces as -// "Failed to fetch dynamically imported module", and `MODULE_NOT_FOUND` -// is a Node/CJS code that only appears under jsdom/vitest, not the -// shipped bundle. So a transient chunk-load failure can be misread as -// "missing" and silently disable whatever the consumer gates on it — -// a route in the routers, the status banner in PageLayout — for that -// session. Same outcome as a bare `catch`, but genuine errors in the -// present-plugin case at least get logged. Centralized so any future -// hardening lands once. -// -// Zero-dependency on purpose: consumers include modules that import -// each other (Router/useMainAppRoutes/PageLayout), and this helper -// must never re-introduce an import cycle. -export function isModuleMissing(err) { +// "Failed to fetch dynamically imported module" is deliberately NOT here: it +// is a plugin that IS shipped but whose chunk failed to load (CDN/origin +// blip, stale hashed asset, or a syntax error the dev server refused to +// serve). That is a real failure and must be logged, not mistaken for absence. +export function isPluginAbsent(err) { const msg = err?.message || ""; return ( - err?.code === "MODULE_NOT_FOUND" || msg.includes("Optional plugin not available") || - msg.includes("Failed to fetch dynamically imported module") || + err?.code === "MODULE_NOT_FOUND" || msg.includes("Cannot find module") ); } + +// Load an optional plugin, resolving to `fallback` when it is unavailable. +// +// const TrialDaysInfo = await loadPlugin(() => +// import("../plugins/x/TrialDaysInfo.jsx").then((m) => m.default), +// ); +// +// - `importer` is a thunk so the literal `../plugins/...` path stays at the +// call site, where Vite can statically resolve it (and stub it in OSS). +// Have it return exactly the value you need — pick the export in `.then`. +// - Resolves to `fallback` when the plugin is absent, when the import fails +// for any other reason, or when the importer yields `undefined`/`null` +// (e.g. the export was renamed, or vitest's empty stub module). +// - Absence is the expected OSS case and stays silent. Every other failure is +// logged here, so a broken plugin is never indistinguishable from a +// missing one. +// +// Load ONE plugin module per call: sharing a call (or a try block) between +// plugins lets one missing plugin disable the others. +export async function loadPlugin(importer, fallback = null) { + try { + return (await importer()) ?? fallback; + } catch (err) { + if (!isPluginAbsent(err)) { + reportPluginLoadError(err); + } + return fallback; + } +} + +function reportPluginLoadError(err) { + console.error("[plugin] failed to load; using the fallback instead", err); +} diff --git a/frontend/src/helpers/pluginLoader.test.js b/frontend/src/helpers/pluginLoader.test.js new file mode 100644 index 0000000000..90b01af654 --- /dev/null +++ b/frontend/src/helpers/pluginLoader.test.js @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isPluginAbsent, loadPlugin } from "./pluginLoader.js"; + +const absent = () => Promise.reject(new Error("Optional plugin not available")); + +describe("loadPlugin", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves to what the importer returns", async () => { + const Comp = () => null; + await expect(loadPlugin(() => Promise.resolve(Comp))).resolves.toBe(Comp); + }); + + it("keeps falsy values that are not null/undefined", async () => { + await expect(loadPlugin(() => Promise.resolve(false), true)).resolves.toBe( + false, + ); + }); + + it("falls back silently when the plugin is absent", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const fallback = () => null; + + await expect(loadPlugin(absent, fallback)).resolves.toBe(fallback); + expect(error).not.toHaveBeenCalled(); + }); + + it("defaults the fallback to null", async () => { + await expect(loadPlugin(absent)).resolves.toBeNull(); + }); + + it("falls back when the picked export is missing", async () => { + await expect( + loadPlugin(() => Promise.resolve({}).then((m) => m.Missing), "fb"), + ).resolves.toBe("fb"); + }); + + it("logs a broken plugin and still falls back", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom = new Error("boom at module evaluation"); + + await expect(loadPlugin(() => Promise.reject(boom), "fb")).resolves.toBe( + "fb", + ); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("[plugin]"), + boom, + ); + }); + + it("logs a chunk that failed to fetch instead of treating it as absent", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const chunk = new TypeError( + "Failed to fetch dynamically imported module: /assets/x-abc.js", + ); + + await expect(loadPlugin(() => Promise.reject(chunk))).resolves.toBeNull(); + expect(error).toHaveBeenCalledOnce(); + }); + + it("catches an importer that throws synchronously", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await expect( + loadPlugin(() => { + throw new Error("sync"); + }, "fb"), + ).resolves.toBe("fb"); + }); +}); + +describe("isPluginAbsent", () => { + it.each([ + [new Error("Optional plugin not available"), true], + [Object.assign(new Error("x"), { code: "MODULE_NOT_FOUND" }), true], + [new Error("Cannot find module '../plugins/x'"), true], + [ + new TypeError("Failed to fetch dynamically imported module: /a.js"), + false, + ], + [new Error("boom"), false], + [undefined, false], + ])("classifies %s as absent=%s", (err, expected) => { + expect(isPluginAbsent(err)).toBe(expected); + }); +}); diff --git a/frontend/src/helpers/pluginRegistry.js b/frontend/src/helpers/pluginRegistry.js index 5191dfc53b..7a412873fe 100644 --- a/frontend/src/helpers/pluginRegistry.js +++ b/frontend/src/helpers/pluginRegistry.js @@ -1,20 +1,7 @@ import { lazy } from "react"; import { NotFound } from "../components/error/NotFound/NotFound.jsx"; - -// The only error that means "this plugin was not shipped" is the build-time -// stub vite.config.js's `optionalPluginImports` resolves a missing optional -// plugin to: `throw new Error('Optional plugin not available')`. We match that -// exact signal and nothing else. -// -// We deliberately do NOT reuse the broader `isModuleMissing` here: it also -// matches "Failed to fetch dynamically imported module", which is a TRANSIENT -// chunk-load failure of a plugin that IS shipped (CDN/origin blip, stale hashed -// asset). Treating that as "absent" would silently render NotFound for a real, -// momentarily-unreachable route instead of surfacing the failure. -function isPluginAbsent(err) { - return (err?.message || "").includes("Optional plugin not available"); -} +import { isPluginAbsent } from "./pluginLoader.js"; // Wrap an enterprise plugin's dynamic import as a lazy route element. The // plugin chunk is fetched only when the element actually renders (i.e. on diff --git a/frontend/src/hooks/usePromptOutput.js b/frontend/src/hooks/usePromptOutput.js index 6f4ef40e4d..ebd95ed31c 100644 --- a/frontend/src/hooks/usePromptOutput.js +++ b/frontend/src/hooks/usePromptOutput.js @@ -1,47 +1,37 @@ import { useParams } from "react-router-dom"; +import { loadPlugin } from "../helpers/pluginLoader.js"; import { useCustomToolStore } from "../store/custom-tool-store"; import { usePromptOutputStore } from "../store/prompt-output-store"; import { useSessionStore } from "../store/session-store"; import { useTokenUsageStore } from "../store/token-usage-store"; import { useAxiosPrivate } from "./useAxiosPrivate"; -let promptOutputApiSps; -try { - const mod = await import("../plugins/simple-prompt-studio/helper"); - promptOutputApiSps = mod.promptOutputApiSps; -} catch { - // The component will remain null of it is not available -} -let publicOutputsApi; -try { - const mod = await import( - "../plugins/prompt-studio-public-share/helpers/PublicShareAPIs" - ); - publicOutputsApi = mod.publicOutputsApi; -} catch { - // The component will remain null of it is not available -} - -let handleLookupOutput; -try { - const mod = await import( - "../plugins/lookup-studio/prompt-card/handleLookupOutput" - ); - handleLookupOutput = mod.handleLookupOutput; -} catch (error) { - // Surface chunk-load failures — silent catch hid them. - console.warn("[usePromptOutput] handleLookupOutput unavailable:", error); -} +const promptOutputApiSps = await loadPlugin(() => + import("../plugins/simple-prompt-studio/helper").then( + (m) => m.promptOutputApiSps, + ), +); +const publicOutputsApi = await loadPlugin(() => + import("../plugins/prompt-studio-public-share/helpers/PublicShareAPIs").then( + (m) => m.publicOutputsApi, + ), +); + +const handleLookupOutput = await loadPlugin(() => + import("../plugins/lookup-studio/prompt-card/handleLookupOutput").then( + (m) => m.handleLookupOutput, + ), +); // Cloud-only extractor; OSS no-op. Signature matches plugin helper. -let getEnrichmentFromItem = (_item) => null; -try { - const mod = await import("../plugins/lookup-enriched-toggle/helpers"); - getEnrichmentFromItem = mod.getEnrichmentFromItem; -} catch (error) { - console.warn("[usePromptOutput] getEnrichmentFromItem unavailable:", error); -} +const getEnrichmentFromItem = await loadPlugin( + () => + import("../plugins/lookup-enriched-toggle/helpers").then( + (m) => m.getEnrichmentFromItem, + ), + (_item) => null, +); const usePromptOutput = () => { const { sessionDetails } = useSessionStore(); diff --git a/frontend/src/hooks/useSessionValid.js b/frontend/src/hooks/useSessionValid.js index a2c2c0e092..d96636d4e7 100644 --- a/frontend/src/hooks/useSessionValid.js +++ b/frontend/src/hooks/useSessionValid.js @@ -3,33 +3,27 @@ import Cookies from "js-cookie"; import { useNavigate } from "react-router-dom"; import { listFlags } from "../helpers/FeatureFlagsData.js"; import { getSessionData } from "../helpers/GetSessionData"; +import { loadPlugin } from "../helpers/pluginLoader.js"; import { useExceptionHandler } from "../hooks/useExceptionHandler.jsx"; import { useAlertStore } from "../store/alert-store"; import { useSessionStore } from "../store/session-store"; import { useUserSession } from "./useUserSession.js"; -let isPlatformAdmin; -try { - const mod = await import("../plugins/hooks/usePlatformAdmin.js"); - isPlatformAdmin = mod.usePlatformAdmin(); -} catch { - // Plugin not available -} +const isPlatformAdmin = await loadPlugin(() => + import("../plugins/hooks/usePlatformAdmin.js").then((m) => + m.usePlatformAdmin(), + ), +); let selectedProduct; -let selectedProductStore; -let PRODUCT_NAMES = {}; - -try { - selectedProductStore = await import( - "../plugins/store/select-product-store.js" - ); - const helperMod = await import("../plugins/llm-whisperer/helper"); - // Guard against empty/missing module export - PRODUCT_NAMES = helperMod.PRODUCT_NAMES ?? {}; -} catch { - // Ignore if hook not available -} +const selectedProductStore = await loadPlugin( + () => import("../plugins/store/select-product-store.js"), +); +const { PRODUCT_NAMES = {} } = await loadPlugin( + () => import("../plugins/llm-whisperer/helper"), + {}, +); + function useSessionValid() { const setSessionDetails = useSessionStore((state) => state.setSessionDetails); const handleException = useExceptionHandler(); diff --git a/frontend/src/layouts/page-layout/PageLayout.jsx b/frontend/src/layouts/page-layout/PageLayout.jsx index 7a5c6a483e..52ae250644 100644 --- a/frontend/src/layouts/page-layout/PageLayout.jsx +++ b/frontend/src/layouts/page-layout/PageLayout.jsx @@ -11,25 +11,14 @@ import { getLocalStorageValue, setLocalStorageValue, } from "../../helpers/localStorage"; -import { isModuleMissing } from "../../helpers/pluginLoader.js"; +import { loadPlugin } from "../../helpers/pluginLoader.js"; // Optional status banner contributed by the marketplace plugin, when -// present. The plugin is absent in OSS builds — the import fails and -// nothing is mounted. The banner self-manages its visibility. -let MarketplacePendingBanner; -try { - const marketplaceMod = await import("../../plugins/marketplace"); - MarketplacePendingBanner = marketplaceMod.MarketplacePendingBanner; -} catch (err) { - // Missing plugin is the expected case; surface anything else so a - // broken plugin doesn't silently unmount its UI. - if (!isModuleMissing(err)) { - console.error( - "[marketplace] MarketplacePendingBanner import failed unexpectedly", - err, - ); - } -} +// present. The plugin is absent in OSS builds, so nothing is mounted. The +// banner self-manages its visibility. +const MarketplacePendingBanner = await loadPlugin(() => + import("../../plugins/marketplace").then((m) => m.MarketplacePendingBanner), +); function PageLayout({ sideBarOptions, diff --git a/frontend/src/pages/CustomTools.jsx b/frontend/src/pages/CustomTools.jsx index 0864d9d262..9277586d94 100644 --- a/frontend/src/pages/CustomTools.jsx +++ b/frontend/src/pages/CustomTools.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import { ListOfTools } from "../components/custom-tools/list-of-tools/ListOfTools"; +import { loadPlugin } from "../helpers/pluginLoader.js"; const TAB_OPTIONS = ["Projects", "Look-Ups"]; @@ -13,9 +14,13 @@ function CustomTools() { ); useEffect(() => { - import("../plugins/lookup-studio") - .then((mod) => setLookupListComp(() => mod.LookupList)) - .catch(() => {}); + loadPlugin(() => + import("../plugins/lookup-studio").then((m) => m.LookupList), + ).then((LookupList) => { + if (LookupList) { + setLookupListComp(() => LookupList); + } + }); }, []); useEffect(() => { diff --git a/frontend/src/pages/UnstractAdministrationPage.jsx b/frontend/src/pages/UnstractAdministrationPage.jsx index c51486c2b7..634d3fd014 100644 --- a/frontend/src/pages/UnstractAdministrationPage.jsx +++ b/frontend/src/pages/UnstractAdministrationPage.jsx @@ -1,17 +1,13 @@ import "./UnstractAdministrationPage.css"; +import { loadPlugin } from "../helpers/pluginLoader.js"; import { useSessionStore } from "../store/session-store"; -let UnstractAdministration; - -try { - const mod = await import( +// Cloud-only feature, not available in OSS +const UnstractAdministration = await loadPlugin(() => + import( "../plugins/subscription-admin/components/UnstractAdministration.jsx" - ); - UnstractAdministration = mod.UnstractAdministration; -} catch { - // NOSONAR - // Cloud-only feature, not available in OSS -} + ).then((m) => m.UnstractAdministration), +); function UnstractAdministrationPage() { const { sessionDetails } = useSessionStore(); diff --git a/frontend/src/routes/Router.jsx b/frontend/src/routes/Router.jsx index 05bcea6b18..45e099f013 100644 --- a/frontend/src/routes/Router.jsx +++ b/frontend/src/routes/Router.jsx @@ -14,7 +14,7 @@ import { RequireGuest } from "../components/helpers/auth/RequireGuest.jsx"; import { OAuthStatus } from "../components/oauth-ds/oauth-status/OAuthStatus.jsx"; import { ErrorBoundary } from "../components/widgets/error-boundary/ErrorBoundary.jsx"; import { lazyNamed } from "../helpers/lazyNamed.js"; -import { isModuleMissing } from "../helpers/pluginLoader.js"; +import { loadPlugin } from "../helpers/pluginLoader.js"; import { lazyPlugin } from "../helpers/pluginRegistry.js"; import { LandingPage } from "../pages/LandingPage.jsx"; import { useMainAppRoutes } from "./useMainAppRoutes.jsx"; @@ -120,30 +120,22 @@ const LlmWhispererCustomCheckoutPage = lazyPlugin( // These plugins export hooks that RETURN a tree consumed // synchronously during render, so they cannot be wrapped in React.lazy and -// are loaded with a guarded await. OSS resolves these to the stub (caught -// below); cloud loads them. NOTE: in cloud these two modules still load on +// are loaded with loadPlugin at module evaluation. OSS resolves these to the +// stub; cloud loads them. NOTE: in cloud these two modules still load on // /landing because the await runs at module evaluation. Fully deferring them // requires the plugins themselves (in unstract-cloud) to lazy-load their own // page imports — tracked as a follow-up. -let llmWhispererRouter; -try { - const mod = await import("../plugins/routes/useLlmWhispererRoutes.js"); - llmWhispererRouter = mod.useLlmWhispererRoutes; -} catch (err) { - if (!isModuleMissing(err)) { - console.error("[llm-whisperer] routes import failed unexpectedly", err); - } -} +const llmWhispererRouter = await loadPlugin(() => + import("../plugins/routes/useLlmWhispererRoutes.js").then( + (m) => m.useLlmWhispererRoutes, + ), +); -let verticalsRouter; -try { - const mod = await import("../plugins/routes/useVerticalsRoutes.js"); - verticalsRouter = mod.useVerticalsRoutes; -} catch (err) { - if (!isModuleMissing(err)) { - console.error("[verticals] routes import failed unexpectedly", err); - } -} +const verticalsRouter = await loadPlugin(() => + import("../plugins/routes/useVerticalsRoutes.js").then( + (m) => m.useVerticalsRoutes, + ), +); function Router() { const location = useLocation(); diff --git a/frontend/src/routes/useMainAppRoutes.jsx b/frontend/src/routes/useMainAppRoutes.jsx index b8234c44b3..2f71ca183f 100644 --- a/frontend/src/routes/useMainAppRoutes.jsx +++ b/frontend/src/routes/useMainAppRoutes.jsx @@ -7,7 +7,7 @@ import { DefaultTriad } from "../components/settings/default-triad/DefaultTriad. import { PlatformSettings } from "../components/settings/platform/PlatformSettings.jsx"; import { deploymentTypes } from "../helpers/GetStaticData.js"; import { lazyNamed } from "../helpers/lazyNamed.js"; -import { isModuleMissing } from "../helpers/pluginLoader.js"; +import { loadPlugin } from "../helpers/pluginLoader.js"; import { lazyPlugin } from "../helpers/pluginRegistry.js"; // Route pages are code-split (via the shared lazyNamed helper) so they are @@ -188,17 +188,12 @@ const MarketplaceStripeConflictPage = lazyPlugin( ); // PRODUCT_NAMES is a data value read synchronously below to decide the route -// tree, so it cannot be lazy — load it with a guarded await (cloud only; OSS -// resolves to the stub and is caught). -let PRODUCT_NAMES = {}; -try { - const mod = await import("../plugins/llm-whisperer/helper.js"); - PRODUCT_NAMES = mod.PRODUCT_NAMES ?? {}; -} catch (err) { - if (!isModuleMissing(err)) { - console.error("[llm-whisperer] helper import failed unexpectedly", err); - } -} +// tree, so it cannot be lazy — load it with loadPlugin (cloud only; OSS +// resolves to the stub and falls back to {}). +const { PRODUCT_NAMES = {} } = await loadPlugin( + () => import("../plugins/llm-whisperer/helper.js"), + {}, +); // NOTE: the old "ReadOnlyReviewPage loaded but ReviewLayout missing" warning // was removed — with lazyPlugin both wrappers are always truthy, so the check diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 128c7f042f..ac4d35be6f 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -40,8 +40,8 @@ function jsxInJs() { * Mirror of optionalPluginImports() in vite.config.js — the same asymmetry the * note above warns about, in a second place. * - * `src/helpers/GetStaticData.js` does `try { await import("../plugins/...") }`, - * which the build resolves to an empty module when the cloud plugin tree is + * `src/helpers/GetStaticData.js` does + * `await loadPlugin(() => import("../plugins/..."))`, which the build resolves to an empty module when the cloud plugin tree is * absent. Vitest does not read vite.config.js, so in the OSS-only checkout any * test importing a component that reaches GetStaticData failed to COLLECT — * reported as a failed file, not a failed assertion, and easy to read as