4

Information about my app (Angular 12):

  • consists of 3 Modules, each has a overview page with a list and some detail pages
  • every route has an area tag so I know in which module the user is navigating

So I wanted to implement Angular´s RouteReuseStrategy for the following behaviour:

  • Whenever a user navigates from list -> detail page and uses back button, the list component should be reused (detect back button trigger)

  • Whenever a user navigates to the list component from a different module / area the list component should not be reused

  • Whenever a user navigates from detail to another detail page, the detail component should be reused (default behaviour?)

  • Whenever a user leaves a module, by navigating to another or logging out, the stored components should be cleared / destroyed

Current Situation:

  • I implemented a custom RouteReuseStrategy, which works and the list component is reused ✓

    • Only the scroll position is not restored, but this needs to be checked seperately ✕
  • I wanted to check the areatag inside the route, but the ActivatedRouteSnapshots are empty ✕

  • Detecting the backbutton press, but events fire to often and it breaks if I implement a basic back flag ✕

What is missing?

  • Detect backbutton navigation and modify the reuse of the component

  • Detect which module the route is part of to modify reuse or clean up the stored components

Code:

Example route in Module A

{
  path: 'lista',
  component: ListAComponent,
  data: {
    title: 'List overview',
    areaCategory: AreaCategory.A,
    reuseRoute: true,
  },
},
{
  path: 'lista/:id',
  component: DetailAComponent,
  data: {
    title: 'Detail',
    areaCategory: AreaCategory.A,
    reuseRoute: false,
  },
},

Example route Module B

{
  path: 'listb',
  component: ListBComponent,
  data: {
    title: 'List overview',
    areaCategory: AreaCategory.B,
    reuseRoute: true,
  },
},
{
  path: 'listb/:id',
  component: DetailBComponent,
  data: {
    title: 'Detail',
    areaCategory: AreaCategory.B,
    reuseRoute: false,
  },
},

app.module.ts

providers: [
{
  provide: RouteReuseStrategy,
  useClass: CustomReuseRouteStrategy,
}
],

Should be fine here globally or do I need to move it to each of the 3 modules?

ReuseRouteStrategy

@Injectable()
export class CustomReuseRouteStrategy implements RouteReuseStrategy {
  private handlers: { [key: string]: DetachedRouteHandle } = {};
  
  // Detect Backbutton-navigation
  back = false;
  constructor(location: LocationStrategy) {
    location.onPopState(() => {
      this.back = true;
    });
  }

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    if (!route.routeConfig || route.routeConfig.loadChildren) {
      return false;
    }

    // Check route.data.reuse whether this route should be re used or not

    let shouldReuse = false;
    if (
      route.routeConfig.data &&
      route.routeConfig.data.reuseRoute &&
      typeof route.routeConfig.data.reuseRoute === 'boolean'
    ) {
      shouldReuse = route.routeConfig.data.reuseRoute;
    }
    return shouldReuse;
  }

 
  store(route: ActivatedRouteSnapshot, handler: DetachedRouteHandle): void {
    if (handler) {
      this.handlers[this.getUrl(route)] = handler;
    }
  }

  
  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    if (!this.back) {
      return false;
    }
    return !!this.handlers[this.getUrl(route)];
  }

  
  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    if (!this.back || !route.routeConfig || route.routeConfig.loadChildren) {
      return null;
    }

    //this.back = false; -> does not work fires to often

    return this.handlers[this.getUrl(route)];
  }

  
  shouldReuseRoute(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean {
    /** We only want to reuse the route if the data of the route config contains a reuse true boolean */

    let reUseUrl = false;
    if (future.routeConfig && future.routeConfig.data && typeof future.routeConfig.data.reuseRoute === 'boolean') {
      reUseUrl = future.routeConfig.data.reuseRoute;
    }

    //const defaultReuse = future.routeConfig === current.routeConfig; -> used for navigating to same component but routeConfigs are empty therefore always match?
    return reUseUrl;
  }

  private getUrl(route: ActivatedRouteSnapshot): string {
    if (route.routeConfig) {
      const url = route.routeConfig.path;
      return url;
    }
  }

  clearHandles() {
    for (const key in this.handlers) {
      if (this.handlers[key]) {
        this.destroyHandle(this.handlers[key]);
      }
    }
    this.handlers = {};
  }

  private destroyHandle(handle: DetachedRouteHandle): void {
    const componentRef: ComponentRef<any> = handle['componentRef'];
    if (componentRef) {
      componentRef.destroy();
    }
  }
}

I noticed _routerState inside the ActivatedRouteSnapshot holds the url, which could be used to differentiate the modules, but I´d rather check the areaCategory from the route data but weirdly, the future & current ActivatedRouteSnapshots in the shouldReuseRoute method are mostly empty

Also I am not sure about using internal values like _routerState, since I heard these are not fixed an can change at any time

Log of empty future & current snapshot (only url is useful)

Current & Future Route show AppComponent

Why is it AppComponent instead of Detail or ListAComponent? Maybe thats why the data is empty?

I need to get the correct route / component to access the areaCategory to implement the desired behavior.

As requested here is a simple stackblitz with my setup

If I missed something, please let me know, would really appreciate the help

stackg91
  • 584
  • 7
  • 25
  • you can use this to detect back button clicks on a component ```@HostListener('window:popstate', ['$event']) private onPopState(event) { // your logic }``` – Ali Demirci Aug 26 '21 at 08:43
  • hey yes I already have location.onPopState(() => { this.back = true; }); but resetting the flag back to false doesnt really work because the events get fired on every route a few times and without having route properties to compare them I just doesnt work correctly – stackg91 Aug 26 '21 at 10:48
  • Can you create a stackblitz? – Gourav Garg Aug 27 '21 at 06:34
  • @GouravGarg I added the stackblitz link to my question, hope this helps – stackg91 Aug 30 '21 at 07:10
  • you could listen to router events instead of location and record them to global service or variable or session store, this way you will know previous routes. – kemsky Aug 30 '21 at 17:43

1 Answers1

3

This respects your conditions

  1. Whenever a user navigates from list -> detail page and uses back button, the list component should be reused (detect back button trigger). Yes. Assuming you do not have list -> detail -> list navigations
  2. Whenever a user navigates to the list component from a different module / area the list component should not be reused. Yes. Assuming a different area can not have the same areaCategory tag
  3. Whenever a user navigates from detail to another detail page, the detail component should be reused (default behaviour?). Yes
  4. Whenever a user leaves a module, by navigating to another or logging out, the stored components should be cleared / destroyed Yes, same as 2.

I'm adding you some extra comments and a diagram to understand RouteReuseStrategy calling which will make more clear how to use it.

Follows the diagram and the pseudocode that shows how the strategy is used by Angular core (it is based on my observations, I have found no official documentation):

enter image description here

transition(current, future) {
  if (shouldReuseRoute(future, current)) {
    // No navigation is performed, same route is recycled 
    return current;
  } else {
    if (shouldDetach(current)) {
      // If not reused and shouldDetach() == true then store
      store(current, getRouteHandler(current));
    }
    if (shouldAttach(future)) {
      // If not reused and shouldAttach() == true then retrieve
      return createRouteFromHandler(retrieve(future));
    } else {
      // If shouldAttach() == false do not recycle
      return future;
    }
  }
}

Of course, this is an example. getRouteHandler and createRouteFromHandler are introduced just as an example and no distinction is made between route component, route instance, and route snapshot.

@Injectable()
export class CustomReuseRouteStrategy implements RouteReuseStrategy {
  private handlers: { [key: string]: DetachedRouteHandle } = {};

  clearHandlers() {
    Object.keys(this.handlers).forEach(h => {
      // https://github.com/angular/angular/issues/15873
      (h as any).componentRef.destroy();
    })
    this.handlers = {};
  }

  areSameArea(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean {
    return future.routeConfig.data && current.routeConfig.data
      && future.routeConfig.data.areaCategory === current.routeConfig.data.areaCategory;
  }

  /**
   * This function decides weather the current route should be kept.
   * If this function returns `true` nor attach or detach procedures are called
   * (hence none of shouldDetach, shouldAttach, store, retrieve). If this function
   * returns `false` an attach/detach procedure is initiated.
   */
  shouldReuseRoute(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean {
    console.log('shouldReuseRoute', future, current);

    if (!this.areSameArea(future, current)) {
      // Changed area, clear the cache
      this.clearHandlers();
    }

    return this.getUrl(future) === this.getUrl(current);
  }

  /**
   * DETACH PROCEDURE: if performing a detach, this function is called, if returns
   * `true` then store is called, to store the current context.
   */
  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    console.log('shouldDetach', route);
    // We always detach them (you never mentioned pages that are not recycled
    // by default)
    return true;
  }

  store(route: ActivatedRouteSnapshot, handler: DetachedRouteHandle): void {
    console.log('store', route, this.getUrl(route));
    if (!handler && this.getUrl(route)) return;
    this.handlers[this.getUrl(route)] = handler;
  }

  /**
   * ATTACH PROCEDURE: if performing an attach, this function is called, if returns
   * `true` then retrieve is called, to store the current context.
   */
  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    console.log('shouldAttach', route);
    return !!this.handlers[this.getUrl(route)];
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    console.log('retrieve', route, this.getUrl(route));
    return this.getUrl(route) && this.handlers[this.getUrl(route)];
  }


  private getUrl(route: ActivatedRouteSnapshot): string {
    // todo : not sure this behaves properly in case of parametric routes
    return route.routeConfig && route.routeConfig.path;
  }

}

Newbie
  • 4,462
  • 11
  • 23
  • Thank you for taking the time, really appreciate it. The problem currently is that inside the shouldReuseRoute method both parameters future & current contain information about the app component instead of DetailComponent (future) & ListComponent (current), that is why even if I want to check for future.routeConfig.data.areaCategory it is missing since the AppComponent doesnt have route data :(, not sure why maybe its because its an SPA (see the screenshot at the end of my question there you see the log of current & future Route) – stackg91 Sep 01 '21 at 06:25
  • I do see the log, they are not the same. One si "/lista" and the other is "/lista/1". But both of them are missing "routerConfig". Perhaps you misconfigured routing? You may have a nested routung? – Newbie Sep 03 '21 at 12:49
  • You can see from the stackblitz that this is very simple routing and the only config change is the reuseRouteStrategy – stackg91 Sep 04 '21 at 10:20