-
Notifications
You must be signed in to change notification settings - Fork 632
UN-3185: Defer heavy chunks on login route + cache fingerprinted assets #2114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
425feac
UN-3185: Defer heavy chunks on login route + cache fingerprinted assets
jaseemjaskp 020ef55
UN-3185: Address review — preserve security headers, tighten plugin-a…
jaseemjaskp 9864b3f
UN-3185: Extract shared lazyNamed helper (dedupe lazy-route boilerplate)
jaseemjaskp c2679bc
UN-3185: Add ErrorBoundary around lazy routes; validate lazyNamed export
jaseemjaskp 37334e5
UN-3185: Scope route error handling, validate lazyPlugin export, drop…
jaseemjaskp dba2a6a
UN-3185: Use globalThis.location.reload() in RouteLoadError (SonarClo…
jaseemjaskp 10f6da8
UN-3185: Auto-reload once on chunk-load errors (stale chunk after dep…
jaseemjaskp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| }), | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| throw err; | ||
| }), | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.