diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d38c93820b..3acc55381f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -37,6 +37,17 @@ http { uwsgi_temp_path /tmp/uwsgi_temp 1 2; scgi_temp_path /tmp/scgi_temp 1 2; + # Cache policy by URL. Vite emits content-hashed filenames under /assets/, + # so those bytes never change and can be cached forever; everything else + # (index.html, runtime-config.js, SPA routes) must stay uncached so new + # deploys are picked up. Driven from a single server-level add_header so it + # composes with the security headers instead of overriding them (nginx does + # not merge add_header across location/server scopes). + map $uri $cache_control { + default "no-cache"; + ~^/assets/ "public, max-age=31536000, immutable"; + } + server { listen 80; root /usr/share/nginx/html; @@ -50,11 +61,27 @@ http { # CSP in report-only mode: logs violations to browser console without blocking requests. add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://eu.i.posthog.com https://eu-assets.i.posthog.com https://www.googletagmanager.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://js.stripe.com https://app.productfruits.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://eu.i.posthog.com https://eu-assets.i.posthog.com; font-src 'self' data:; connect-src 'self' blob: wss: https://cdn.jsdelivr.net https://eu.i.posthog.com https://eu-assets.i.posthog.com https://www.google-analytics.com https://api.stripe.com https://app.productfruits.com; frame-src 'self' https://www.google.com/recaptcha/ https://recaptcha.google.com https://js.stripe.com https://hooks.stripe.com; worker-src 'self' blob: https://unpkg.com https://cdn.jsdelivr.net; object-src 'none'; base-uri 'self'; form-action 'self' https://checkout.stripe.com; frame-ancestors 'self'" always; + # Cache-Control from the $cache_control map (immutable for /assets/*, + # no-cache otherwise). Declared at server scope alongside the security + # headers so a location block does not have to redeclare add_header + # (which would drop the inherited security headers). + add_header Cache-Control $cache_control always; + # Disable TRACE and TRACK methods if ($request_method ~ ^(TRACE|TRACK)$) { return 405; } + # Content-hashed build output. No add_header here, so the server-level + # security + cache headers are inherited. 404 (not the SPA fallback) + # for a missing asset. + location /assets/ { + limit_except GET HEAD { + deny all; + } + try_files $uri =404; + } + # If a react app URI is directly accessed we will get 404 # since there will be no file representing that path. # Below config will load index.html file in such case and diff --git a/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx b/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx new file mode 100644 index 0000000000..ce321c9c60 --- /dev/null +++ b/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx @@ -0,0 +1,82 @@ +import { Button, Result } from "antd"; +import { Suspense } from "react"; +import { Outlet, useLocation } from "react-router-dom"; + +import { GenericLoader } from "../../generic-loader/GenericLoader.jsx"; +import { ErrorBoundary } from "../../widgets/error-boundary/ErrorBoundary.jsx"; + +// A failed dynamic import — almost always a stale hashed chunk after a +// redeploy (the client's index.html references a filename the CDN no longer +// serves). Matching is best-effort across browsers/bundlers. +export function isChunkLoadError(err) { + const msg = err?.message || ""; + return ( + err?.name === "ChunkLoadError" || + msg.includes("Failed to fetch dynamically imported module") || + msg.includes("error loading dynamically imported module") || + msg.includes("Importing a module script failed") + ); +} + +// onError handler for the route boundaries. On a chunk-load error, reload ONCE +// to pick up fresh chunk hashes from the new deploy. A timestamp guard prevents +// a reload loop when the chunk is genuinely gone: a repeat failure inside the +// window falls through to the manual "Reload" fallback instead. Non-chunk +// errors (real render bugs) are left for the fallback — never auto-reloaded. +export function handleRouteError({ error }) { + if (!isChunkLoadError(error)) { + return; + } + try { + const KEY = "route-chunk-reload-ts"; + const last = Number(sessionStorage.getItem(KEY) || 0); + if (Date.now() - last > 10000) { + sessionStorage.setItem(KEY, String(Date.now())); + globalThis.location.reload(); + } + } catch { + // sessionStorage unavailable (private mode, etc.) — skip the auto-reload + // and let the manual fallback handle recovery. + } +} + +// Shown when a lazy route chunk fails to load (a transient network/CDN blip, or +// a stale hashed asset after a deploy). lazyPlugin/lazyNamed rethrow such +// failures; without a boundary React would unmount the tree to a blank screen. +// Reloading re-fetches the chunk, so that is the offered recovery. +export function RouteLoadError() { + return ( + globalThis.location.reload()}> + Reload + + } + /> + ); +} + +// Renders the routed page () inside a CONTENT-SCOPED Suspense and a +// navigation-resettable ErrorBoundary. Use it inside a persistent layout in +// place of a bare so the shell (sidebar/topnav) stays mounted: +// per-page load spinners and chunk-load failures are confined to the content +// area, and navigating away (the shell nav stays interactive) recovers without +// a full reload. The app-wide boundary in Router.jsx remains the backstop for +// shell-level failures and routes that don't use a layout. +export function LazyOutlet() { + const location = useLocation(); + return ( + } + > + }> + + + + ); +} diff --git a/frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx b/frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx index 72e00db6af..a83aeee3e3 100644 --- a/frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx +++ b/frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx @@ -4,6 +4,12 @@ import { Component } from "react"; const { Text } = Typography; +// Shallow element-wise compare; resetKeys are typically fresh arrays each +// render (e.g. [location.pathname]), so a reference check would never match. +function resetKeysChanged(prev = [], next = []) { + return prev.length !== next.length || prev.some((v, i) => v !== next[i]); +} + class ErrorBoundary extends Component { constructor(props) { super(props); @@ -16,6 +22,18 @@ class ErrorBoundary extends Component { this.setState({ error }); } + componentDidUpdate(prevProps) { + // Once a value in resetKeys changes (e.g. the user navigated), clear the + // error so the boundary re-renders its children — recovery without a + // full page reload. + if ( + this.state.error && + resetKeysChanged(prevProps.resetKeys, this.props.resetKeys) + ) { + this.setState({ error: undefined }); + } + } + render() { return this.state.error ? this.props.fallbackComponent @@ -37,6 +55,12 @@ ErrorBoundary.propTypes = { * By default, it shows "There was an error" message */ fallbackComponent: PropTypes.any, + /** + * When any value in this array changes while in the error state, the + * boundary resets and re-renders its children (e.g. [location.pathname] + * so navigation recovers without a reload). + */ + resetKeys: PropTypes.array, }; ErrorBoundary.defaultProps = { diff --git a/frontend/src/helpers/lazyNamed.js b/frontend/src/helpers/lazyNamed.js new file mode 100644 index 0000000000..0f7775c46b --- /dev/null +++ b/frontend/src/helpers/lazyNamed.js @@ -0,0 +1,23 @@ +import { lazy } from "react"; + +// Lazy-load a module's NAMED export as a route component. React.lazy expects a +// module with a `default` export, so this adapts `{ Foo }` to `{ default: Foo }`. +// Use it to code-split route pages/layouts that use named exports without +// repeating the `.then((m) => ({ default: m.X }))` boilerplate at every site. +// +// For DEFAULT-exported components, use `lazy(() => import(...))` directly. +// For OPTIONAL enterprise plugins that may be absent in OSS, use `lazyPlugin` +// (pluginRegistry.js) instead — it adds the missing-plugin NotFound fallback. +export function lazyNamed(loader, exportName) { + return lazy(() => + loader().then((m) => { + const component = m?.[exportName]; + if (!component) { + // Fail loudly with the offending name instead of handing React.lazy + // `{ default: undefined }`, which surfaces as an opaque render error. + throw new Error(`lazyNamed: module has no export "${exportName}"`); + } + return { default: component }; + }), + ); +} diff --git a/frontend/src/helpers/pluginRegistry.js b/frontend/src/helpers/pluginRegistry.js new file mode 100644 index 0000000000..5191dfc53b --- /dev/null +++ b/frontend/src/helpers/pluginRegistry.js @@ -0,0 +1,59 @@ +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"); +} + +// 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 +// navigation to its route), so plugins are never pulled onto the +// unauthenticated /landing page — which is the whole point of this change. +// +// The import thunk is written at the call site so the literal `../plugins/...` +// path stays statically analyzable: only entrypoints a route actually +// references enter the build graph (an unreferenced/broken plugin file can't +// fail the build), and vite.config.js's `optionalPluginImports` can resolve +// the path to its stub in OSS builds. +// +// Registration is unconditional. In OSS the stub makes the import reject, which +// we map to the app's NotFound page so the route harmlessly 404s — the same +// user-visible result as the previous "route not registered" branch. Any other +// failure (incl. a transient chunk-load error of a shipped plugin) is re-thrown +// so it surfaces instead of being silently swallowed. +export function lazyPlugin(loader, exportName = "default") { + return lazy(() => + loader() + .then((m) => { + const component = m[exportName] ?? m.default; + if (!component) { + // The plugin loaded but the expected export is gone (renamed/ + // removed). Fail loudly with the offending name instead of handing + // React.lazy `{ default: undefined }`. isPluginAbsent won't match + // this message, so it re-throws to the ErrorBoundary rather than + // masquerading as an absent plugin. + throw new Error( + `lazyPlugin: module loaded but has no export "${exportName}" (or default)`, + ); + } + return { default: component }; + }) + .catch((err) => { + if (isPluginAbsent(err)) { + return { default: NotFound }; + } + throw err; + }), + ); +} diff --git a/frontend/src/layouts/fullpage-payout/FullPageLayout.jsx b/frontend/src/layouts/fullpage-payout/FullPageLayout.jsx index d5b8b61d36..31ef2ffaa0 100644 --- a/frontend/src/layouts/fullpage-payout/FullPageLayout.jsx +++ b/frontend/src/layouts/fullpage-payout/FullPageLayout.jsx @@ -1,11 +1,12 @@ import { Layout } from "antd"; -import { Outlet } from "react-router-dom"; import "./FullPageLayout.css"; +import { LazyOutlet } from "../../components/error/LazyOutlet/LazyOutlet.jsx"; + function FullPageLayout() { return ( - + ); } diff --git a/frontend/src/layouts/page-layout/PageLayout.jsx b/frontend/src/layouts/page-layout/PageLayout.jsx index 79deac2e15..63bccb877b 100644 --- a/frontend/src/layouts/page-layout/PageLayout.jsx +++ b/frontend/src/layouts/page-layout/PageLayout.jsx @@ -1,9 +1,9 @@ import { Layout } from "antd"; import PropTypes from "prop-types"; import { useEffect, useState } from "react"; -import { Outlet } from "react-router-dom"; import "./PageLayout.css"; +import { LazyOutlet } from "../../components/error/LazyOutlet/LazyOutlet.jsx"; import { DisplayLogsAndNotifications } from "../../components/logs-and-notifications/DisplayLogsAndNotifications.jsx"; import SideNavBar from "../../components/navigations/side-nav-bar/SideNavBar.jsx"; import { TopNavBar } from "../../components/navigations/top-nav-bar/TopNavBar.jsx"; @@ -57,7 +57,7 @@ function PageLayout({ )} {MarketplacePendingBanner && } - + {!hideSidebar &&
} {showLogsAndNotifications && } diff --git a/frontend/src/routes/Router.jsx b/frontend/src/routes/Router.jsx index 5c43ae4d59..4f307a0199 100644 --- a/frontend/src/routes/Router.jsx +++ b/frontend/src/routes/Router.jsx @@ -1,18 +1,48 @@ -import { Route, Routes } from "react-router-dom"; +import { Suspense } from "react"; +import { Route, Routes, useLocation } from "react-router-dom"; import { GenericError } from "../components/error/GenericError/GenericError.jsx"; +import { + handleRouteError, + RouteLoadError, +} from "../components/error/LazyOutlet/LazyOutlet.jsx"; import { NotFound } from "../components/error/NotFound/NotFound.jsx"; +import { GenericLoader } from "../components/generic-loader/GenericLoader.jsx"; import { PersistentLogin } from "../components/helpers/auth/PersistentLogin.js"; import { RequireAuth } from "../components/helpers/auth/RequireAuth.js"; import { RequireGuest } from "../components/helpers/auth/RequireGuest.js"; 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 { lazyPlugin } from "../helpers/pluginRegistry.js"; import { LandingPage } from "../pages/LandingPage.jsx"; -import { OutputAnalyzerPage } from "../pages/OutputAnalyzerPage.jsx"; -import { SetOrgPage } from "../pages/SetOrgPage.jsx"; -import { ToolIdePage } from "../pages/ToolIdePage.jsx"; import { useMainAppRoutes } from "./useMainAppRoutes.js"; +// Heavy route pages are code-split so the unauthenticated /landing page does +// not download their chunks (PDF viewer, Monaco, charts) before login. The +// single boundary below covers these as well as the lazy pages +// rendered by useMainAppRoutes (they share this ). +const OutputAnalyzerPage = lazyNamed( + () => import("../pages/OutputAnalyzerPage.jsx"), + "OutputAnalyzerPage", +); +const SetOrgPage = lazyNamed( + () => import("../pages/SetOrgPage.jsx"), + "SetOrgPage", +); +const ToolIdePage = lazyNamed( + () => import("../pages/ToolIdePage.jsx"), + "ToolIdePage", +); + +// Enterprise plugin route elements are code-split (see lazyPlugin). Each +// returns a React.lazy component whose chunk loads only on navigation — so +// none are fetched on the unauthenticated /landing page. In OSS the import +// resolves to a stub and the element falls back to NotFound, so these routes +// harmlessly 404 instead of crashing. The `{Component && }` guards +// below are retained but are always truthy with this pattern. + // Marketplace buyer pages must be reachable at TOP-LEVEL paths: Tackle's // post-purchase redirect is one static URL per environment // (https:///marketplace-landing?...) and cannot carry a per-buyer @@ -21,215 +51,197 @@ import { useMainAppRoutes } from "./useMainAppRoutes.js"; // routes are the marketplace entry points. The landing page handles its // own auth (redirects unauthenticated buyers through signup preserving // the query params), so they sit outside RequireAuth. -let MarketplaceLandingEntry; -let MarketplaceStripeConflictEntry; -try { - const marketplaceMod = await import("../plugins/marketplace"); - MarketplaceLandingEntry = marketplaceMod.MarketplaceLandingPage; - MarketplaceStripeConflictEntry = marketplaceMod.MarketplaceStripeConflictPage; -} catch (err) { - // Expected in OSS builds where the cloud plugin is absent. - if (!isModuleMissing(err)) { - // eslint-disable-next-line no-console - console.error( - "[marketplace] Plugin import failed unexpectedly; marketplace " + - "entry routes disabled", - err, - ); - } -} +const MarketplaceLandingEntry = lazyPlugin( + () => import("../plugins/marketplace"), + "MarketplaceLandingPage", +); +const MarketplaceStripeConflictEntry = lazyPlugin( + () => import("../plugins/marketplace"), + "MarketplaceStripeConflictPage", +); -let PublicPromptStudioHelper; - -// Import pages/components related to Simple Prompt Studio. -let SimplePromptStudioHelper; -let SimplePromptStudio; -let SpsLanding; -let SpsUpload; -let PaymentSuccessful; -let SelectProduct; -let UnstractSubscriptionEndPage; -let CustomPlanCheckoutPage; -try { - const spsHelperMod = await import( - "../plugins/simple-prompt-studio/SimplePromptStudioHelper.jsx" - ); - SimplePromptStudioHelper = spsHelperMod.SimplePromptStudioHelper; - const spsMod = await import( - "../plugins/simple-prompt-studio/SimplePromptStudio.jsx" - ); - SimplePromptStudio = spsMod.SimplePromptStudio; - const spsLandingMod = await import( - "../plugins/simple-prompt-studio/SpsLanding.jsx" - ); - SpsLanding = spsLandingMod.SpsLanding; - const spsUploadMod = await import( - "../plugins/simple-prompt-studio/SpsUpload.jsx" - ); - SpsUpload = spsUploadMod.SpsUpload; -} catch { - // Do nothing, Not-found Page will be triggered. -} -try { - const mod = await import( - "../plugins/prompt-studio-public-share/helpers/PublicPromptStudioHelper.js" - ); - PublicPromptStudioHelper = mod.PublicPromptStudioHelper; -} catch { - // Do nothing, Not-found Page will be triggered. -} +// Simple Prompt Studio pages. +const SimplePromptStudioHelper = lazyPlugin( + () => import("../plugins/simple-prompt-studio/SimplePromptStudioHelper.jsx"), + "SimplePromptStudioHelper", +); +const SimplePromptStudio = lazyPlugin( + () => import("../plugins/simple-prompt-studio/SimplePromptStudio.jsx"), + "SimplePromptStudio", +); +const SpsLanding = lazyPlugin( + () => import("../plugins/simple-prompt-studio/SpsLanding.jsx"), + "SpsLanding", +); +const SpsUpload = lazyPlugin( + () => import("../plugins/simple-prompt-studio/SpsUpload.jsx"), + "SpsUpload", +); +const PublicPromptStudioHelper = lazyPlugin( + () => + import( + "../plugins/prompt-studio-public-share/helpers/PublicPromptStudioHelper.js" + ), + "PublicPromptStudioHelper", +); +const SelectProduct = lazyPlugin( + () => import("../plugins/select-product/SelectProduct.jsx"), + "SelectProduct", +); +const UnstractSubscriptionEndPage = lazyPlugin( + () => + import( + "../plugins/unstract-subscription/pages/UnstractSubscriptionEndPage.jsx" + ), + "UnstractSubscriptionEndPage", +); +const CustomPlanCheckoutPage = lazyPlugin( + () => + import("../plugins/unstract-subscription/pages/CustomPlanCheckoutPage.jsx"), + "CustomPlanCheckoutPage", +); +const PaymentSuccessful = lazyPlugin( + () => import("../plugins/payment-successful/PaymentSuccessful.jsx"), + "PaymentSuccessful", +); +const LlmWhispererCustomCheckoutPage = lazyPlugin( + () => + import("../plugins/llm-whisperer/pages/LlmWhispererCustomCheckoutPage.jsx"), + "LlmWhispererCustomCheckoutPage", +); +// 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 +// /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 { - // Do nothing, Not-found Page will be triggered. +} catch (err) { + if (!isModuleMissing(err)) { + // eslint-disable-next-line no-console + console.error("[llm-whisperer] routes import failed unexpectedly", err); + } } let verticalsRouter; try { const mod = await import("../plugins/routes/useVerticalsRoutes.js"); verticalsRouter = mod.useVerticalsRoutes; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -try { - const mod = await import("../plugins/select-product/SelectProduct.jsx"); - SelectProduct = mod.SelectProduct; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -try { - const mod = await import( - "../plugins/unstract-subscription/pages/UnstractSubscriptionEndPage.jsx" - ); - UnstractSubscriptionEndPage = mod.UnstractSubscriptionEndPage; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -try { - const mod = await import( - "../plugins/unstract-subscription/pages/CustomPlanCheckoutPage.jsx" - ); - CustomPlanCheckoutPage = mod.CustomPlanCheckoutPage; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -try { - const mod = await import( - "../plugins/payment-successful/PaymentSuccessful.jsx" - ); - PaymentSuccessful = mod.PaymentSuccessful; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -let LlmWhispererCustomCheckoutPage; -try { - const mod = await import( - "../plugins/llm-whisperer/pages/LlmWhispererCustomCheckoutPage.jsx" - ); - LlmWhispererCustomCheckoutPage = mod.LlmWhispererCustomCheckoutPage; -} catch { - // NOSONAR - // Do nothing, Not-found Page will be triggered. +} catch (err) { + if (!isModuleMissing(err)) { + // eslint-disable-next-line no-console + console.error("[verticals] routes import failed unexpectedly", err); + } } function Router() { + const location = useLocation(); const MainAppRoute = useMainAppRoutes(); return ( - - } /> - }> - {/* public routes */} - - {/* public routes accessible only to unauthenticated users */} - }> - } /> - + // App-wide backstop: catches shell-level chunk failures and errors on + // routes that don't sit inside a LazyOutlet layout. Per-page load errors + // are handled by the scoped boundaries inside the layouts. resetKeys lets + // navigation clear the error without a full reload. + } + > + }> + + } /> + }> + {/* public routes */} + + {/* public routes accessible only to unauthenticated users */} + }> + } /> + + + {/* public routes accessible to both authenticated and unauthenticated users */} + {SimplePromptStudioHelper && + SimplePromptStudio && + SpsLanding && + SpsUpload && ( + } + > + } /> + } /> + } /> + + )} + {PublicPromptStudioHelper && ( + } + > + } /> + } + /> + + )} + - {/* public routes accessible to both authenticated and unauthenticated users */} - {SimplePromptStudioHelper && - SimplePromptStudio && - SpsLanding && - SpsUpload && ( + {/* protected routes */} + } /> + {SelectProduct && ( + } /> + )} + {UnstractSubscriptionEndPage && ( } - > - } /> - } /> - } /> - + path="/subscription-expired" + element={} + /> + )} + {PaymentSuccessful && ( + } /> + )} + {MarketplaceLandingEntry && ( + } + /> + )} + {MarketplaceStripeConflictEntry && ( + } + /> + )} + {CustomPlanCheckoutPage && ( + } + /> )} - {PublicPromptStudioHelper && ( - } - > - } /> + {LlmWhispererCustomCheckoutPage && ( } + path="/llm-whisperer/custom-checkout" + element={} /> + )} + }> + {MainAppRoute} + {llmWhispererRouter && ( + {llmWhispererRouter()} + )} - )} - - - {/* protected routes */} - } /> - {SelectProduct && ( - } /> - )} - {UnstractSubscriptionEndPage && ( - } - /> - )} - {PaymentSuccessful && ( - } /> - )} - {MarketplaceLandingEntry && ( - } - /> - )} - {MarketplaceStripeConflictEntry && ( - } - /> - )} - {CustomPlanCheckoutPage && ( - } - /> - )} - {LlmWhispererCustomCheckoutPage && ( - } - /> - )} - }> - {MainAppRoute} - {llmWhispererRouter && ( - {llmWhispererRouter()} - )} - - {verticalsRouter && verticalsRouter()} - - } /> - } /> - + {verticalsRouter && verticalsRouter()} + + } /> + } /> + + + ); } diff --git a/frontend/src/routes/useMainAppRoutes.js b/frontend/src/routes/useMainAppRoutes.js index e748fa3768..b6a4c4c49c 100644 --- a/frontend/src/routes/useMainAppRoutes.js +++ b/frontend/src/routes/useMainAppRoutes.js @@ -1,3 +1,4 @@ +import { lazy } from "react"; import { Route } from "react-router-dom"; import { RequireAdmin } from "../components/helpers/auth/RequireAdmin.js"; import { CustomToolsHelper } from "../components/helpers/custom-tools/CustomToolsHelper.js"; @@ -5,185 +6,203 @@ import { ProjectHelper } from "../components/helpers/project/ProjectHelper.js"; import { DefaultTriad } from "../components/settings/default-triad/DefaultTriad.jsx"; 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 { FullPageLayout } from "../layouts/fullpage-payout/FullPageLayout.jsx"; -import { PageLayout } from "../layouts/page-layout/PageLayout.jsx"; -import { AgencyPage } from "../pages/AgencyPage.jsx"; -import ConnectorsPage from "../pages/ConnectorsPage.jsx"; -import { CustomTools } from "../pages/CustomTools.jsx"; -import { DeploymentsPage } from "../pages/DeploymentsPage.jsx"; -import { GroupsPage } from "../pages/GroupsPage.jsx"; -import { InviteEditUserPage } from "../pages/InviteEditUserPage.jsx"; -import { LogsPage } from "../pages/LogsPage.jsx"; -import { MetricsDashboardPage } from "../pages/MetricsDashboardPage.jsx"; -import { OnBoardPage } from "../pages/OnBoardPage.jsx"; -import { OutputAnalyzerPage } from "../pages/OutputAnalyzerPage.jsx"; -import { PlatformApiKeysPage } from "../pages/PlatformApiKeysPage.jsx"; -import { ProfilePage } from "../pages/ProfilePage.jsx"; -import { SettingsPage } from "../pages/SettingsPage.jsx"; -import { ToolIdePage } from "../pages/ToolIdePage.jsx"; -import { ToolsSettingsPage } from "../pages/ToolsSettingsPage.jsx"; -import { UnstractAdministrationPage } from "../pages/UnstractAdministrationPage.jsx"; -import { UsersPage } from "../pages/UsersPage.jsx"; -import { WorkflowsPage } from "../pages/WorkflowsPage.jsx"; +import { lazyPlugin } from "../helpers/pluginRegistry.js"; -let RequirePlatformAdmin; -let PlatformAdminPage; -let AppDeployments; -let ChatAppPage; -let ChatAppLayout; -let ManualReviewSettings; -let OnboardProduct; -let PRODUCT_NAMES = {}; -let ManualReviewPage; -let SimpleManualReviewPage; -let ReviewLayout; -let Manage; -let ReadOnlyReviewPage; -let UnstractSubscriptionPage; -let UnstractSubscriptionCheck; -let AgenticPromptStudio; - -try { - const mod1 = await import( - "../plugins/frictionless-onboard/RequirePlatformAdmin.jsx" - ); - RequirePlatformAdmin = mod1.RequirePlatformAdmin; - const mod2 = await import( - "../plugins/frictionless-onboard/platform-admin-page/PlatformAdminPage.jsx" - ); - PlatformAdminPage = mod2.PlatformAdminPage; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -try { - const mod = await import("../plugins/agentic-prompt-studio"); - AgenticPromptStudio = mod.AgenticPromptStudio; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -let LookupStudio; -try { - const mod = await import("../plugins/lookup-studio"); - LookupStudio = mod.LookupStudio; -} catch { - // Do nothing, Not-found Page will be triggered. -} +// Route pages are code-split (via the shared lazyNamed helper) so they are +// only fetched when navigated to, not eagerly on the unauthenticated /landing +// page. The boundary that renders these lives in Router.jsx +// (shared ). -try { - const mod1 = await import("../plugins/app-deployment/AppDeployments.jsx"); - AppDeployments = mod1.AppDeployments; - const mod2 = await import( - "../plugins/app-deployment/chat-app/ChatAppPage.jsx" - ); - ChatAppPage = mod2.ChatAppPage; - const mod3 = await import( - "../plugins/app-deployment/chat-app/ChatAppLayout.jsx" - ); - ChatAppLayout = mod3.ChatAppLayout; -} catch { - // Do nothing, Not-found Page will be triggered. -} +// The authenticated app shell is lazy too: it statically pulls in heavy nav +// widgets (which themselves eager-load plugins like lookup-studio), so keeping +// it eager would drag that whole graph onto /landing even though the shell +// never renders pre-login. +const FullPageLayout = lazyNamed( + () => import("../layouts/fullpage-payout/FullPageLayout.jsx"), + "FullPageLayout", +); +const PageLayout = lazyNamed( + () => import("../layouts/page-layout/PageLayout.jsx"), + "PageLayout", +); -try { - const mod = await import("../plugins/manual-review/settings/Settings.jsx"); - ManualReviewSettings = mod.ManualReviewSettings; -} catch { - // Do nothing, Not-found Page will be triggered. -} +const AgencyPage = lazyNamed( + () => import("../pages/AgencyPage.jsx"), + "AgencyPage", +); +const ConnectorsPage = lazy(() => import("../pages/ConnectorsPage.jsx")); // default export +const CustomTools = lazyNamed( + () => import("../pages/CustomTools.jsx"), + "CustomTools", +); +const DeploymentsPage = lazyNamed( + () => import("../pages/DeploymentsPage.jsx"), + "DeploymentsPage", +); +const GroupsPage = lazyNamed( + () => import("../pages/GroupsPage.jsx"), + "GroupsPage", +); +const InviteEditUserPage = lazyNamed( + () => import("../pages/InviteEditUserPage.jsx"), + "InviteEditUserPage", +); +const LogsPage = lazyNamed(() => import("../pages/LogsPage.jsx"), "LogsPage"); +const MetricsDashboardPage = lazyNamed( + () => import("../pages/MetricsDashboardPage.jsx"), + "MetricsDashboardPage", +); +const OnBoardPage = lazyNamed( + () => import("../pages/OnBoardPage.jsx"), + "OnBoardPage", +); +const OutputAnalyzerPage = lazyNamed( + () => import("../pages/OutputAnalyzerPage.jsx"), + "OutputAnalyzerPage", +); +const PlatformApiKeysPage = lazyNamed( + () => import("../pages/PlatformApiKeysPage.jsx"), + "PlatformApiKeysPage", +); +const ProfilePage = lazyNamed( + () => import("../pages/ProfilePage.jsx"), + "ProfilePage", +); +const SettingsPage = lazyNamed( + () => import("../pages/SettingsPage.jsx"), + "SettingsPage", +); +const ToolIdePage = lazyNamed( + () => import("../pages/ToolIdePage.jsx"), + "ToolIdePage", +); +const ToolsSettingsPage = lazyNamed( + () => import("../pages/ToolsSettingsPage.jsx"), + "ToolsSettingsPage", +); +const UnstractAdministrationPage = lazyNamed( + () => import("../pages/UnstractAdministrationPage.jsx"), + "UnstractAdministrationPage", +); +const UsersPage = lazyNamed( + () => import("../pages/UsersPage.jsx"), + "UsersPage", +); +const WorkflowsPage = lazyNamed( + () => import("../pages/WorkflowsPage.jsx"), + "WorkflowsPage", +); -try { - const mod1 = await import("../plugins/onboard-product/OnboardProduct.jsx"); - OnboardProduct = mod1.OnboardProduct; - const mod2 = await import("../plugins/llm-whisperer/helper.js"); - PRODUCT_NAMES = mod2.PRODUCT_NAMES ?? {}; -} catch { - // Do nothing. -} - -try { - const mod1 = await import( - "../plugins/manual-review/page/ManualReviewPage.jsx" - ); - ManualReviewPage = mod1.ManualReviewPage; - const mod2 = await import( - "../plugins/manual-review/review-layout/ReviewLayout.jsx" - ); - ReviewLayout = mod2.ReviewLayout; - const mod3 = await import( - "../plugins/manual-review/page/simple/SimpleManualReviewPage.jsx" - ); - SimpleManualReviewPage = mod3.SimpleManualReviewPage; - const mod4 = await import("../plugins/manual-review/page/manage/Manage.jsx"); - Manage = mod4.Manage; -} catch { - // Do nothing, Not-found Page will be triggered. -} +// Enterprise plugin route elements — code-split (see lazyPlugin). Each returns +// a React.lazy component whose chunk loads only on navigation, so none are +// fetched on the /landing page. In OSS the import resolves to a stub and the +// element falls back to NotFound (route harmlessly 404s). The +// `{Component && }` guards below are retained but always truthy. +const RequirePlatformAdmin = lazyPlugin( + () => import("../plugins/frictionless-onboard/RequirePlatformAdmin.jsx"), + "RequirePlatformAdmin", +); +const PlatformAdminPage = lazyPlugin( + () => + import( + "../plugins/frictionless-onboard/platform-admin-page/PlatformAdminPage.jsx" + ), + "PlatformAdminPage", +); +const AgenticPromptStudio = lazyPlugin( + () => import("../plugins/agentic-prompt-studio"), + "AgenticPromptStudio", +); +const LookupStudio = lazyPlugin( + () => import("../plugins/lookup-studio"), + "LookupStudio", +); +const AppDeployments = lazyPlugin( + () => import("../plugins/app-deployment/AppDeployments.jsx"), + "AppDeployments", +); +const ChatAppPage = lazyPlugin( + () => import("../plugins/app-deployment/chat-app/ChatAppPage.jsx"), + "ChatAppPage", +); +const ChatAppLayout = lazyPlugin( + () => import("../plugins/app-deployment/chat-app/ChatAppLayout.jsx"), + "ChatAppLayout", +); +const ManualReviewSettings = lazyPlugin( + () => import("../plugins/manual-review/settings/Settings.jsx"), + "ManualReviewSettings", +); +const OnboardProduct = lazyPlugin( + () => import("../plugins/onboard-product/OnboardProduct.jsx"), + "OnboardProduct", +); +const ManualReviewPage = lazyPlugin( + () => import("../plugins/manual-review/page/ManualReviewPage.jsx"), + "ManualReviewPage", +); +const ReviewLayout = lazyPlugin( + () => import("../plugins/manual-review/review-layout/ReviewLayout.jsx"), + "ReviewLayout", +); +const SimpleManualReviewPage = lazyPlugin( + () => + import("../plugins/manual-review/page/simple/SimpleManualReviewPage.jsx"), + "SimpleManualReviewPage", +); +const Manage = lazyPlugin( + () => import("../plugins/manual-review/page/manage/Manage.jsx"), + "Manage", +); +const ReadOnlyReviewPage = lazyPlugin( + () => import("../plugins/prompt-change-indicator/ReadOnlyReviewPage.jsx"), + "ReadOnlyReviewPage", +); +const UnstractSubscriptionPage = lazyPlugin( + () => + import( + "../plugins/unstract-subscription/pages/UnstractSubscriptionPage.jsx" + ), + "UnstractSubscriptionPage", +); +const UnstractSubscriptionCheck = lazyPlugin( + () => + import( + "../plugins/unstract-subscription/components/UnstractSubscriptionCheck.jsx" + ), + "UnstractSubscriptionCheck", +); +const MarketplaceLandingPage = lazyPlugin( + () => import("../plugins/marketplace"), + "MarketplaceLandingPage", +); +const MarketplaceStripeConflictPage = lazyPlugin( + () => import("../plugins/marketplace"), + "MarketplaceStripeConflictPage", +); +// 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/prompt-change-indicator/ReadOnlyReviewPage.jsx" - ); - ReadOnlyReviewPage = mod.ReadOnlyReviewPage; + const mod = await import("../plugins/llm-whisperer/helper.js"); + PRODUCT_NAMES = mod.PRODUCT_NAMES ?? {}; } catch (err) { - // Expected in OSS builds where the cloud plugin is absent. Surface - // anything that isn't a missing-module error so syntax/runtime - // failures inside the plugin don't silently disable the route. if (!isModuleMissing(err)) { // eslint-disable-next-line no-console - console.error( - "[prompt-change-indicator] ReadOnlyReviewPage import failed unexpectedly", - err, - ); + console.error("[llm-whisperer] helper import failed unexpectedly", err); } } -// The readonly route lives inside the manual-review ReviewLayout. If the -// prompt-change-indicator plugin ships without manual-review, the route -// would silently never register — surface that misconfiguration loudly. -if (ReadOnlyReviewPage && !ReviewLayout) { - // eslint-disable-next-line no-console - console.warn( - "[prompt-change-indicator] ReadOnlyReviewPage loaded but ReviewLayout " + - "is missing; readonly route will not be registered.", - ); -} - -try { - const mod1 = await import( - "../plugins/unstract-subscription/pages/UnstractSubscriptionPage.jsx" - ); - UnstractSubscriptionPage = mod1.UnstractSubscriptionPage; - const mod3 = await import( - "../plugins/unstract-subscription/components/UnstractSubscriptionCheck.jsx" - ); - UnstractSubscriptionCheck = mod3.UnstractSubscriptionCheck; -} catch { - // Do nothing, Not-found Page will be triggered. -} - -let MarketplaceLandingPage; -let MarketplaceStripeConflictPage; -try { - const mod = await import("../plugins/marketplace"); - MarketplaceLandingPage = mod.MarketplaceLandingPage; - MarketplaceStripeConflictPage = mod.MarketplaceStripeConflictPage; -} catch (err) { - // Expected in OSS builds where the cloud plugin is absent. Surface - // anything that isn't a missing-module error so syntax/runtime - // failures inside the plugin don't silently de-register the routes. - // (See isModuleMissing for the browser-classification limitation.) - if (!isModuleMissing(err)) { - // eslint-disable-next-line no-console - console.error( - "[marketplace] Plugin import failed unexpectedly; marketplace " + - "routes disabled", - err, - ); - } -} +// NOTE: the old "ReadOnlyReviewPage loaded but ReviewLayout missing" warning +// was removed — with lazyPlugin both wrappers are always truthy, so the check +// could never fire. If prompt-change-indicator ever ships without +// manual-review, ReviewLayout's import rejects at render and falls back to +// NotFound (lazyPlugin), so the readonly route degrades to a 404 rather than +// crashing — no silent total failure to guard against. function useMainAppRoutes() { const routes = ( @@ -334,12 +353,13 @@ function useMainAppRoutes() { ); - if (OnboardProduct && Object.keys(PRODUCT_NAMES)?.length) { + // Gate on the exact value passed as `type` (PRODUCT_NAMES.unstract) rather + // than on the map being non-empty, so we never wrap every route in + // OnboardProduct with an undefined product type. + const unstractProduct = PRODUCT_NAMES?.unstract; + if (OnboardProduct && unstractProduct) { return ( - } - > + }> }> {routes}