Skip to content
27 changes: 27 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/components/error/LazyOutlet/LazyOutlet.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Result
status="warning"
title="Couldn't load this page"
subTitle="Part of the app failed to load — this is usually a temporary network issue. Reloading should fix it."
extra={
<Button type="primary" onClick={() => globalThis.location.reload()}>
Reload
</Button>
}
/>
);
}

// Renders the routed page (<Outlet/>) inside a CONTENT-SCOPED Suspense and a
// navigation-resettable ErrorBoundary. Use it inside a persistent layout in
// place of a bare <Outlet/> 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 (
<ErrorBoundary
resetKeys={[location.pathname]}
onError={handleRouteError}
fallbackComponent={<RouteLoadError />}
>
<Suspense fallback={<GenericLoader />}>
<Outlet />
</Suspense>
</ErrorBoundary>
);
}
24 changes: 24 additions & 0 deletions frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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 = {
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/helpers/lazyNamed.js
Original file line number Diff line number Diff line change
@@ -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 };
}),
);
}
59 changes: 59 additions & 0 deletions frontend/src/helpers/pluginRegistry.js
Original file line number Diff line number Diff line change
@@ -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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
throw err;
}),
);
}
5 changes: 3 additions & 2 deletions frontend/src/layouts/fullpage-payout/FullPageLayout.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Layout className="container">
<Outlet />
<LazyOutlet />
</Layout>
);
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/layouts/page-layout/PageLayout.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -57,7 +57,7 @@ function PageLayout({
)}
<Layout>
{MarketplacePendingBanner && <MarketplacePendingBanner />}
<Outlet />
<LazyOutlet />
{!hideSidebar && <div className="height-40" />}
{showLogsAndNotifications && <DisplayLogsAndNotifications />}
</Layout>
Expand Down
Loading