fix(shell): keep the shell between modules and cancel abandoned requests

- Route reuse treats the shell of every feature module as one, so a click from one module
  into another no longer rebuilds the shell (menu bar vanishing, menu, notification,
  business date and approval-stream calls repeated on every such click)
- The HTTP cache interceptor cancels the inner request when its caller walks away, so
  abandoned streams stop holding browser connections and queueing later requests
- /approvals/counts is not cached

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:51:06 +05:30
co-authored by Claude Sonnet 5.5
parent d032dd7e75
commit e78e44f917
3 changed files with 39 additions and 15 deletions
+11 -4
View File
@@ -59,7 +59,10 @@ export class CacheInterceptor implements HttpInterceptor {
'/notes',
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
'/almreturn'
'/almreturn',
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
'/approvals/counts'
];
private isCacheable(url: string): boolean {
@@ -93,8 +96,9 @@ export class CacheInterceptor implements HttpInterceptor {
// Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as Object));
subscriber.complete();
} else {
next.handle(request).subscribe(
return undefined;
}
const inner = next.handle(request).subscribe(
(event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
@@ -104,7 +108,10 @@ export class CacheInterceptor implements HttpInterceptor {
(error) => subscriber.error(error),
() => subscriber.complete()
);
}
// The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
// streams that were let go but never closed filled them and every later request waited in the queue.
return () => inner.unsubscribe();
});
}
}
+17 -1
View File
@@ -9,6 +9,7 @@
/** Angular Imports */
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
import { Injectable } from '@angular/core';
import { ShellComponent } from '../shell/shell.component';
/**
* A route strategy allowing for explicit route reuse.
@@ -31,7 +32,22 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
return null;
}
/**
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
*/
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig || future.data.reuse;
return (
future.routeConfig === curr.routeConfig ||
future.data.reuse ||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
);
}
private static isShell(route: ActivatedRouteSnapshot): boolean {
return route.routeConfig?.component === ShellComponent;
}
}
+3 -2
View File
@@ -81,8 +81,9 @@ export class ShellComponent implements OnInit, OnDestroy {
.pipe(map((result) => result.matches));
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Progress bar mode. */
progressBarMode: string;
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
* 'none', so an empty start left it running until some request happened to finish. */
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
/** Subscription to progress bar. */
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */