Skip to content

Commit b13e5bd

Browse files
authored
UN-3185: Defer heavy chunks on login route + cache fingerprinted assets (#2114)
* UN-3185: Defer heavy chunks on login route + cache fingerprinted assets Fix 1 (nginx.conf): serve content-hashed /assets/* with a 1y immutable Cache-Control while keeping index.html and config/runtime-config.js non-cached, so repeat visits stop re-fetching the whole bundle. Fix 2: stop the unauthenticated /landing page from eagerly downloading the full app. Convert OSS route pages and enterprise plugin route elements to React.lazy behind a single <Suspense>, and lazy-load the app shell (PageLayout/FullPageLayout) which transitively pulled the PDF viewer, charts and lookup-studio onto /landing. New helper src/helpers/pluginRegistry.js (lazyPlugin) defers plugin chunks to navigation; OSS builds resolve the stub and fall back to NotFound, so absent-plugin routes 404 harmlessly as before. Measured /landing script requests: 201 -> 93; pdf-vendor, recharts and Monaco no longer load until navigated to. OSS-parity build (no src/plugins) verified. * UN-3185: Address review — preserve security headers, tighten plugin-absent check - nginx: drive Cache-Control from a $uri map at server scope instead of per-location add_header. Location-level add_header replaces (not merges) the inherited server headers, which dropped X-Content-Type-Options/ X-Frame-Options/Referrer-Policy/CSP from /assets, index.html and runtime-config.js. Now both locations carry no add_header and inherit all security + cache headers. - pluginRegistry: only treat the build-time stub ('Optional plugin not available') as plugin-absent; rethrow transient chunk-load failures of a shipped plugin instead of masking them as NotFound. - useMainAppRoutes: gate the OnboardProduct wrapper on PRODUCT_NAMES.unstract (the value passed as type) rather than the map being non-empty. * UN-3185: Extract shared lazyNamed helper (dedupe lazy-route boilerplate) Pull the repeated 'lazy a named export' pattern into src/helpers/lazyNamed.js and use it in Router.jsx and useMainAppRoutes.js instead of inline .then((m) => ({ default: m.X })) / a per-file 'named' helper. Behaviour is identical; /landing chunk count unchanged. Addresses cloud-PR review feedback about the duplicated helper (the two route hooks import the same util). * UN-3185: Add ErrorBoundary around lazy routes; validate lazyNamed export - Wrap the route <Suspense> in Router.jsx with the existing ErrorBoundary and a reload-prompt fallback. lazyPlugin/lazyNamed rethrow non-stub failures (e.g. a transient chunk-load blip), and App rendered <Router/> with no boundary — so such a failure would unmount the tree to a blank screen. The boundary now contains it and offers a reload (which re-fetches the chunk). - lazyNamed: throw a descriptive error when the requested named export is missing instead of handing React.lazy { default: undefined } (opaque error). Addresses greptile P1 (no ErrorBoundary) and CodeRabbit (lazyNamed validation). * UN-3185: Scope route error handling, validate lazyPlugin export, drop dead guard Review round 3: - lazyPlugin: validate the resolved export (mirror lazyNamed). A shipped plugin whose named export was renamed/removed now throws a descriptive error instead of handing React.lazy { default: undefined }; isPluginAbsent doesn't match it, so it re-throws to the ErrorBoundary rather than masking as NotFound. - ErrorBoundary: support resetKeys — clear the error when a key changes (e.g. location), so navigation recovers without a full reload. - New LazyOutlet (content-scoped <Suspense> + nav-resettable ErrorBoundary + <Outlet/>); PageLayout/FullPageLayout use it instead of a bare <Outlet/>. Per-page load spinners and chunk-load failures now stay in the content area with the shell mounted; the app-wide boundary in Router.jsx remains the backstop and is now also location-reset. Fixes the blast-radius / no-recovery and shell-blanks-on-first-nav issues from the single top-level boundary. - useMainAppRoutes: remove the now-dead 'ReadOnlyReviewPage && !ReviewLayout' warning — with lazyPlugin both are always truthy so it could never fire; the route degrades to NotFound if manual-review is absent. Build green (with and without src/plugins); biome clean. * UN-3185: Use globalThis.location.reload() in RouteLoadError (SonarCloud S7764) Prefer globalThis over window for the reload handler, matching existing globalThis.location usage in the codebase (e.g. SideNavBar). Clears the only open SonarCloud issue on this PR (javascript:S7764, minor code smell). * UN-3185: Auto-reload once on chunk-load errors (stale chunk after deploy) The route ErrorBoundary already catches a rejected dynamic import() (a <Suspense> alone only handles the pending state). Add chunk-error handling so the common production trigger — a stale hashed chunk after a redeploy (client requests a filename the CDN no longer serves) — auto-recovers: - isChunkLoadError() detects the failed-dynamic-import error across browsers. - handleRouteError() (wired as onError on both the app-wide boundary in Router.jsx and the content-scoped one in LazyOutlet) reloads ONCE to pick up fresh chunk hashes, guarded by a sessionStorage timestamp so a genuinely-gone chunk falls through to the manual Reload fallback instead of looping. Non-chunk render errors are never auto-reloaded. This covers the outermost lazy route elements too (e.g. FullPageLayout for verticals, OnboardProduct for llm-whisperer), which render under the Router boundary.
1 parent 0a997e9 commit b13e5bd

9 files changed

Lines changed: 613 additions & 365 deletions

File tree

frontend/nginx.conf

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ http {
3737
uwsgi_temp_path /tmp/uwsgi_temp 1 2;
3838
scgi_temp_path /tmp/scgi_temp 1 2;
3939

40+
# Cache policy by URL. Vite emits content-hashed filenames under /assets/,
41+
# so those bytes never change and can be cached forever; everything else
42+
# (index.html, runtime-config.js, SPA routes) must stay uncached so new
43+
# deploys are picked up. Driven from a single server-level add_header so it
44+
# composes with the security headers instead of overriding them (nginx does
45+
# not merge add_header across location/server scopes).
46+
map $uri $cache_control {
47+
default "no-cache";
48+
~^/assets/ "public, max-age=31536000, immutable";
49+
}
50+
4051
server {
4152
listen 80;
4253
root /usr/share/nginx/html;
@@ -50,11 +61,27 @@ http {
5061
# CSP in report-only mode: logs violations to browser console without blocking requests.
5162
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;
5263

64+
# Cache-Control from the $cache_control map (immutable for /assets/*,
65+
# no-cache otherwise). Declared at server scope alongside the security
66+
# headers so a location block does not have to redeclare add_header
67+
# (which would drop the inherited security headers).
68+
add_header Cache-Control $cache_control always;
69+
5370
# Disable TRACE and TRACK methods
5471
if ($request_method ~ ^(TRACE|TRACK)$) {
5572
return 405;
5673
}
5774

75+
# Content-hashed build output. No add_header here, so the server-level
76+
# security + cache headers are inherited. 404 (not the SPA fallback)
77+
# for a missing asset.
78+
location /assets/ {
79+
limit_except GET HEAD {
80+
deny all;
81+
}
82+
try_files $uri =404;
83+
}
84+
5885
# If a react app URI is directly accessed we will get 404
5986
# since there will be no file representing that path.
6087
# Below config will load index.html file in such case and
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Button, Result } from "antd";
2+
import { Suspense } from "react";
3+
import { Outlet, useLocation } from "react-router-dom";
4+
5+
import { GenericLoader } from "../../generic-loader/GenericLoader.jsx";
6+
import { ErrorBoundary } from "../../widgets/error-boundary/ErrorBoundary.jsx";
7+
8+
// A failed dynamic import — almost always a stale hashed chunk after a
9+
// redeploy (the client's index.html references a filename the CDN no longer
10+
// serves). Matching is best-effort across browsers/bundlers.
11+
export function isChunkLoadError(err) {
12+
const msg = err?.message || "";
13+
return (
14+
err?.name === "ChunkLoadError" ||
15+
msg.includes("Failed to fetch dynamically imported module") ||
16+
msg.includes("error loading dynamically imported module") ||
17+
msg.includes("Importing a module script failed")
18+
);
19+
}
20+
21+
// onError handler for the route boundaries. On a chunk-load error, reload ONCE
22+
// to pick up fresh chunk hashes from the new deploy. A timestamp guard prevents
23+
// a reload loop when the chunk is genuinely gone: a repeat failure inside the
24+
// window falls through to the manual "Reload" fallback instead. Non-chunk
25+
// errors (real render bugs) are left for the fallback — never auto-reloaded.
26+
export function handleRouteError({ error }) {
27+
if (!isChunkLoadError(error)) {
28+
return;
29+
}
30+
try {
31+
const KEY = "route-chunk-reload-ts";
32+
const last = Number(sessionStorage.getItem(KEY) || 0);
33+
if (Date.now() - last > 10000) {
34+
sessionStorage.setItem(KEY, String(Date.now()));
35+
globalThis.location.reload();
36+
}
37+
} catch {
38+
// sessionStorage unavailable (private mode, etc.) — skip the auto-reload
39+
// and let the manual fallback handle recovery.
40+
}
41+
}
42+
43+
// Shown when a lazy route chunk fails to load (a transient network/CDN blip, or
44+
// a stale hashed asset after a deploy). lazyPlugin/lazyNamed rethrow such
45+
// failures; without a boundary React would unmount the tree to a blank screen.
46+
// Reloading re-fetches the chunk, so that is the offered recovery.
47+
export function RouteLoadError() {
48+
return (
49+
<Result
50+
status="warning"
51+
title="Couldn't load this page"
52+
subTitle="Part of the app failed to load — this is usually a temporary network issue. Reloading should fix it."
53+
extra={
54+
<Button type="primary" onClick={() => globalThis.location.reload()}>
55+
Reload
56+
</Button>
57+
}
58+
/>
59+
);
60+
}
61+
62+
// Renders the routed page (<Outlet/>) inside a CONTENT-SCOPED Suspense and a
63+
// navigation-resettable ErrorBoundary. Use it inside a persistent layout in
64+
// place of a bare <Outlet/> so the shell (sidebar/topnav) stays mounted:
65+
// per-page load spinners and chunk-load failures are confined to the content
66+
// area, and navigating away (the shell nav stays interactive) recovers without
67+
// a full reload. The app-wide boundary in Router.jsx remains the backstop for
68+
// shell-level failures and routes that don't use a layout.
69+
export function LazyOutlet() {
70+
const location = useLocation();
71+
return (
72+
<ErrorBoundary
73+
resetKeys={[location.pathname]}
74+
onError={handleRouteError}
75+
fallbackComponent={<RouteLoadError />}
76+
>
77+
<Suspense fallback={<GenericLoader />}>
78+
<Outlet />
79+
</Suspense>
80+
</ErrorBoundary>
81+
);
82+
}

frontend/src/components/widgets/error-boundary/ErrorBoundary.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { Component } from "react";
44

55
const { Text } = Typography;
66

7+
// Shallow element-wise compare; resetKeys are typically fresh arrays each
8+
// render (e.g. [location.pathname]), so a reference check would never match.
9+
function resetKeysChanged(prev = [], next = []) {
10+
return prev.length !== next.length || prev.some((v, i) => v !== next[i]);
11+
}
12+
713
class ErrorBoundary extends Component {
814
constructor(props) {
915
super(props);
@@ -16,6 +22,18 @@ class ErrorBoundary extends Component {
1622
this.setState({ error });
1723
}
1824

25+
componentDidUpdate(prevProps) {
26+
// Once a value in resetKeys changes (e.g. the user navigated), clear the
27+
// error so the boundary re-renders its children — recovery without a
28+
// full page reload.
29+
if (
30+
this.state.error &&
31+
resetKeysChanged(prevProps.resetKeys, this.props.resetKeys)
32+
) {
33+
this.setState({ error: undefined });
34+
}
35+
}
36+
1937
render() {
2038
return this.state.error
2139
? this.props.fallbackComponent
@@ -37,6 +55,12 @@ ErrorBoundary.propTypes = {
3755
* By default, it shows "There was an error" message
3856
*/
3957
fallbackComponent: PropTypes.any,
58+
/**
59+
* When any value in this array changes while in the error state, the
60+
* boundary resets and re-renders its children (e.g. [location.pathname]
61+
* so navigation recovers without a reload).
62+
*/
63+
resetKeys: PropTypes.array,
4064
};
4165

4266
ErrorBoundary.defaultProps = {

frontend/src/helpers/lazyNamed.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { lazy } from "react";
2+
3+
// Lazy-load a module's NAMED export as a route component. React.lazy expects a
4+
// module with a `default` export, so this adapts `{ Foo }` to `{ default: Foo }`.
5+
// Use it to code-split route pages/layouts that use named exports without
6+
// repeating the `.then((m) => ({ default: m.X }))` boilerplate at every site.
7+
//
8+
// For DEFAULT-exported components, use `lazy(() => import(...))` directly.
9+
// For OPTIONAL enterprise plugins that may be absent in OSS, use `lazyPlugin`
10+
// (pluginRegistry.js) instead — it adds the missing-plugin NotFound fallback.
11+
export function lazyNamed(loader, exportName) {
12+
return lazy(() =>
13+
loader().then((m) => {
14+
const component = m?.[exportName];
15+
if (!component) {
16+
// Fail loudly with the offending name instead of handing React.lazy
17+
// `{ default: undefined }`, which surfaces as an opaque render error.
18+
throw new Error(`lazyNamed: module has no export "${exportName}"`);
19+
}
20+
return { default: component };
21+
}),
22+
);
23+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { lazy } from "react";
2+
3+
import { NotFound } from "../components/error/NotFound/NotFound.jsx";
4+
5+
// The only error that means "this plugin was not shipped" is the build-time
6+
// stub vite.config.js's `optionalPluginImports` resolves a missing optional
7+
// plugin to: `throw new Error('Optional plugin not available')`. We match that
8+
// exact signal and nothing else.
9+
//
10+
// We deliberately do NOT reuse the broader `isModuleMissing` here: it also
11+
// matches "Failed to fetch dynamically imported module", which is a TRANSIENT
12+
// chunk-load failure of a plugin that IS shipped (CDN/origin blip, stale hashed
13+
// asset). Treating that as "absent" would silently render NotFound for a real,
14+
// momentarily-unreachable route instead of surfacing the failure.
15+
function isPluginAbsent(err) {
16+
return (err?.message || "").includes("Optional plugin not available");
17+
}
18+
19+
// Wrap an enterprise plugin's dynamic import as a lazy route element. The
20+
// plugin chunk is fetched only when the element actually renders (i.e. on
21+
// navigation to its route), so plugins are never pulled onto the
22+
// unauthenticated /landing page — which is the whole point of this change.
23+
//
24+
// The import thunk is written at the call site so the literal `../plugins/...`
25+
// path stays statically analyzable: only entrypoints a route actually
26+
// references enter the build graph (an unreferenced/broken plugin file can't
27+
// fail the build), and vite.config.js's `optionalPluginImports` can resolve
28+
// the path to its stub in OSS builds.
29+
//
30+
// Registration is unconditional. In OSS the stub makes the import reject, which
31+
// we map to the app's NotFound page so the route harmlessly 404s — the same
32+
// user-visible result as the previous "route not registered" branch. Any other
33+
// failure (incl. a transient chunk-load error of a shipped plugin) is re-thrown
34+
// so it surfaces instead of being silently swallowed.
35+
export function lazyPlugin(loader, exportName = "default") {
36+
return lazy(() =>
37+
loader()
38+
.then((m) => {
39+
const component = m[exportName] ?? m.default;
40+
if (!component) {
41+
// The plugin loaded but the expected export is gone (renamed/
42+
// removed). Fail loudly with the offending name instead of handing
43+
// React.lazy `{ default: undefined }`. isPluginAbsent won't match
44+
// this message, so it re-throws to the ErrorBoundary rather than
45+
// masquerading as an absent plugin.
46+
throw new Error(
47+
`lazyPlugin: module loaded but has no export "${exportName}" (or default)`,
48+
);
49+
}
50+
return { default: component };
51+
})
52+
.catch((err) => {
53+
if (isPluginAbsent(err)) {
54+
return { default: NotFound };
55+
}
56+
throw err;
57+
}),
58+
);
59+
}

frontend/src/layouts/fullpage-payout/FullPageLayout.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Layout } from "antd";
2-
import { Outlet } from "react-router-dom";
32
import "./FullPageLayout.css";
43

4+
import { LazyOutlet } from "../../components/error/LazyOutlet/LazyOutlet.jsx";
5+
56
function FullPageLayout() {
67
return (
78
<Layout className="container">
8-
<Outlet />
9+
<LazyOutlet />
910
</Layout>
1011
);
1112
}

frontend/src/layouts/page-layout/PageLayout.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Layout } from "antd";
22
import PropTypes from "prop-types";
33
import { useEffect, useState } from "react";
4-
import { Outlet } from "react-router-dom";
54
import "./PageLayout.css";
65

6+
import { LazyOutlet } from "../../components/error/LazyOutlet/LazyOutlet.jsx";
77
import { DisplayLogsAndNotifications } from "../../components/logs-and-notifications/DisplayLogsAndNotifications.jsx";
88
import SideNavBar from "../../components/navigations/side-nav-bar/SideNavBar.jsx";
99
import { TopNavBar } from "../../components/navigations/top-nav-bar/TopNavBar.jsx";
@@ -57,7 +57,7 @@ function PageLayout({
5757
)}
5858
<Layout>
5959
{MarketplacePendingBanner && <MarketplacePendingBanner />}
60-
<Outlet />
60+
<LazyOutlet />
6161
{!hideSidebar && <div className="height-40" />}
6262
{showLogsAndNotifications && <DisplayLogsAndNotifications />}
6363
</Layout>

0 commit comments

Comments
 (0)