168

I have an Angular 2 module in which I have implemented routing and would like the states stored when navigating.
The user should be able to:

  1. search for documents using a 'search formula'
  2. navigate to one of the results
  3. navigate back to 'searchresult' - without communicating with the server

This is possible including RouteReuseStrategy.
The question is:
How do I implement that the document should not be stored?

So the route path "documents"'s state should be stored and the route path "documents/:id"' state should NOT be stored?

Hakan Fıstık
  • 16,800
  • 14
  • 110
  • 131
Anders Gram Mygind
  • 1,725
  • 3
  • 11
  • 11

10 Answers10

296

Hey Anders, great question!

I've got almost the same use case as you, and wanted to do the same thing! User search > get results > User navigates to result > User navigates back > BOOM blazing fast return to results, but you don't want to store the specific result that the user navigated to.

tl;dr

You need to have a class that implements RouteReuseStrategy and provide your strategy in the ngModule. If you want to modify when the route is stored, modify the shouldDetach function. When it returns true, Angular stores the route. If you want to modify when the route is attached, modify the shouldAttach function. When shouldAttach returns true, Angular will use the stored route in place of the requested route. Here's a Plunker for you to play around with.

About RouteReuseStrategy

By having asked this question, you already understand that RouteReuseStrategy allows you to tell Angular not to destroy a component, but in fact to save it for re-rendering at a later date. That's cool because it allows:

  • Decreased server calls
  • Increased speed
  • AND the component renders, by default, in the same state it was left

That last one is important if you would like to, say, leave a page temporarily even though the user has entered a lot of text into it. Enterprise applications will love this feature because of the excessive amount of forms!

This is what I came up with to solve the problem. As you said, you need to make use of the RouteReuseStrategy offered up by @angular/router in versions 3.4.1 and higher.

TODO

First Make sure your project has @angular/router version 3.4.1 or higher.

Next, create a file which will house your class that implements RouteReuseStrategy. I called mine reuse-strategy.ts and placed it in the /app folder for safekeeping. For now, this class should look like:

import { RouteReuseStrategy } from '@angular/router';

export class CustomReuseStrategy implements RouteReuseStrategy {
}

(don't worry about your TypeScript errors, we're about to solve everything)

Finish the groundwork by providing the class to your app.module. Note that you have not yet written CustomReuseStrategy, but should go ahead and import it from reuse-strategy.ts all the same. Also import { RouteReuseStrategy } from '@angular/router';

@NgModule({
    [...],
    providers: [
        {provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
    ]
)}
export class AppModule {
}

The final piece is writing the class which will control whether or not routes get detached, stored, retrieved, and reattached. Before we get to the old copy/paste, I'll do a short explanation of mechanics here, as I understand them. Reference the code below for the methods I'm describing, and of course, there's plenty of documentation in the code.

  1. When you navigate, shouldReuseRoute fires. This one is a little odd to me, but if it returns true, then it actually reuses the route you're currently on and none of the other methods are fired. I just return false if the user is navigating away.
  2. If shouldReuseRoute returns false, shouldDetach fires. shouldDetach determines whether or not you want to store the route, and returns a boolean indicating as much. This is where you should decide to store/not to store paths, which I would do by checking an array of paths you want stored against route.routeConfig.path, and returning false if the path does not exist in the array.
  3. If shouldDetach returns true, store is fired, which is an opportunity for you to store whatever information you would like about the route. Whatever you do, you'll need to store the DetachedRouteHandle because that's what Angular uses to identify your stored component later on. Below, I store both the DetachedRouteHandle and the ActivatedRouteSnapshot into a variable local to my class.

So, we've seen the logic for storage, but what about navigating to a component? How does Angular decide to intercept your navigation and put the stored one in its place?

  1. Again, after shouldReuseRoute has returned false, shouldAttach runs, which is your chance to figure out whether you want to regenerate or use the component in memory. If you want to reuse a stored component, return true and you're well on your way!
  2. Now Angular will ask you, "which component do you want us to use?", which you will indicate by returning that component's DetachedRouteHandle from retrieve.

That's pretty much all the logic you need! In the code for reuse-strategy.ts, below, I've also left you a nifty function that will compare two objects. I use it to compare the future route's route.params and route.queryParams with the stored one's. If those all match up, I want to use the stored component instead of generating a new one. But how you do it is up to you!

reuse-strategy.ts

/**
 * reuse-strategy.ts
 * by corbfon 1/6/17
 */

import { ActivatedRouteSnapshot, RouteReuseStrategy, DetachedRouteHandle } from '@angular/router';

/** Interface for object which can store both: 
 * An ActivatedRouteSnapshot, which is useful for determining whether or not you should attach a route (see this.shouldAttach)
 * A DetachedRouteHandle, which is offered up by this.retrieve, in the case that you do want to attach the stored route
 */
interface RouteStorageObject {
    snapshot: ActivatedRouteSnapshot;
    handle: DetachedRouteHandle;
}

export class CustomReuseStrategy implements RouteReuseStrategy {

    /** 
     * Object which will store RouteStorageObjects indexed by keys
     * The keys will all be a path (as in route.routeConfig.path)
     * This allows us to see if we've got a route stored for the requested path
     */
    storedRoutes: { [key: string]: RouteStorageObject } = {};

    /** 
     * Decides when the route should be stored
     * If the route should be stored, I believe the boolean is indicating to a controller whether or not to fire this.store
     * _When_ it is called though does not particularly matter, just know that this determines whether or not we store the route
     * An idea of what to do here: check the route.routeConfig.path to see if it is a path you would like to store
     * @param route This is, at least as I understand it, the route that the user is currently on, and we would like to know if we want to store it
     * @returns boolean indicating that we want to (true) or do not want to (false) store that route
     */
    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        let detach: boolean = true;
        console.log("detaching", route, "return: ", detach);
        return detach;
    }

    /**
     * Constructs object of type `RouteStorageObject` to store, and then stores it for later attachment
     * @param route This is stored for later comparison to requested routes, see `this.shouldAttach`
     * @param handle Later to be retrieved by this.retrieve, and offered up to whatever controller is using this class
     */
    store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
        let storedRoute: RouteStorageObject = {
            snapshot: route,
            handle: handle
        };

        console.log( "store:", storedRoute, "into: ", this.storedRoutes );
        // routes are stored by path - the key is the path name, and the handle is stored under it so that you can only ever have one object stored for a single path
        this.storedRoutes[route.routeConfig.path] = storedRoute;
    }

    /**
     * Determines whether or not there is a stored route and, if there is, whether or not it should be rendered in place of requested route
     * @param route The route the user requested
     * @returns boolean indicating whether or not to render the stored route
     */
    shouldAttach(route: ActivatedRouteSnapshot): boolean {

        // this will be true if the route has been stored before
        let canAttach: boolean = !!route.routeConfig && !!this.storedRoutes[route.routeConfig.path];

        // this decides whether the route already stored should be rendered in place of the requested route, and is the return value
        // at this point we already know that the paths match because the storedResults key is the route.routeConfig.path
        // so, if the route.params and route.queryParams also match, then we should reuse the component
        if (canAttach) {
            let willAttach: boolean = true;
            console.log("param comparison:");
            console.log(this.compareObjects(route.params, this.storedRoutes[route.routeConfig.path].snapshot.params));
            console.log("query param comparison");
            console.log(this.compareObjects(route.queryParams, this.storedRoutes[route.routeConfig.path].snapshot.queryParams));

            let paramsMatch: boolean = this.compareObjects(route.params, this.storedRoutes[route.routeConfig.path].snapshot.params);
            let queryParamsMatch: boolean = this.compareObjects(route.queryParams, this.storedRoutes[route.routeConfig.path].snapshot.queryParams);

            console.log("deciding to attach...", route, "does it match?", this.storedRoutes[route.routeConfig.path].snapshot, "return: ", paramsMatch && queryParamsMatch);
            return paramsMatch && queryParamsMatch;
        } else {
            return false;
        }
    }

    /** 
     * Finds the locally stored instance of the requested route, if it exists, and returns it
     * @param route New route the user has requested
     * @returns DetachedRouteHandle object which can be used to render the component
     */
    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {

        // return null if the path does not have a routerConfig OR if there is no stored route for that routerConfig
        if (!route.routeConfig || !this.storedRoutes[route.routeConfig.path]) return null;
        console.log("retrieving", "return: ", this.storedRoutes[route.routeConfig.path]);

        /** returns handle when the route.routeConfig.path is already stored */
        return this.storedRoutes[route.routeConfig.path].handle;
    }

    /** 
     * Determines whether or not the current route should be reused
     * @param future The route the user is going to, as triggered by the router
     * @param curr The route the user is currently on
     * @returns boolean basically indicating true if the user intends to leave the current route
     */
    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        console.log("deciding to reuse", "future", future.routeConfig, "current", curr.routeConfig, "return: ", future.routeConfig === curr.routeConfig);
        return future.routeConfig === curr.routeConfig;
    }

    /** 
     * This nasty bugger finds out whether the objects are _traditionally_ equal to each other, like you might assume someone else would have put this function in vanilla JS already
     * One thing to note is that it uses coercive comparison (==) on properties which both objects have, not strict comparison (===)
     * Another important note is that the method only tells you if `compare` has all equal parameters to `base`, not the other way around
     * @param base The base object which you would like to compare another object to
     * @param compare The object to compare to base
     * @returns boolean indicating whether or not the objects have all the same properties and those properties are ==
     */
    private compareObjects(base: any, compare: any): boolean {

        // loop through all properties in base object
        for (let baseProperty in base) {

            // determine if comparrison object has that property, if not: return false
            if (compare.hasOwnProperty(baseProperty)) {
                switch(typeof base[baseProperty]) {
                    // if one is object and other is not: return false
                    // if they are both objects, recursively call this comparison function
                    case 'object':
                        if ( typeof compare[baseProperty] !== 'object' || !this.compareObjects(base[baseProperty], compare[baseProperty]) ) { return false; } break;
                    // if one is function and other is not: return false
                    // if both are functions, compare function.toString() results
                    case 'function':
                        if ( typeof compare[baseProperty] !== 'function' || base[baseProperty].toString() !== compare[baseProperty].toString() ) { return false; } break;
                    // otherwise, see if they are equal using coercive comparison
                    default:
                        if ( base[baseProperty] != compare[baseProperty] ) { return false; }
                }
            } else {
                return false;
            }
        }

        // returns true only after false HAS NOT BEEN returned through all loops
        return true;
    }
}

Behavior

This implementation stores every unique route that the user visits on the router exactly once. This will continue to add to the components stored in memory throughout the user's session on the site. If you'd like to limit the routes that you store, the place to do it is the shouldDetach method. It controls which routes you save.

Example

Say your user searches for something from the homepage, which navigates them to the path search/:term, which might appear like www.yourwebsite.com/search/thingsearchedfor. The search page contains a bunch of search results. You'd like to store this route, in case they want to come back to it! Now they click a search result and get navigated to view/:resultId, which you do not want to store, seeing as they'll probably be there only once. With the above implementation in place, I would simply change the shouldDetach method! Here's what it might look like:

First off let's make an array of paths we want to store.

private acceptedRoutes: string[] = ["search/:term"];

now, in shouldDetach we can check the route.routeConfig.path against our array.

shouldDetach(route: ActivatedRouteSnapshot): boolean {
    // check to see if the route's path is in our acceptedRoutes array
    if (this.acceptedRoutes.indexOf(route.routeConfig.path) > -1) {
        console.log("detaching", route);
        return true;
    } else {
        return false; // will be "view/:resultId" when user navigates to result
    }
}

Because Angular will only store one instance of a route, this storage will be lightweight, and we'll only be storing the component located at search/:term and not all the others!

Additional Links

Although there's not much documentation out there yet, here are a couple links to what does exist:

Angular Docs: https://angular.io/docs/ts/latest/api/router/index/RouteReuseStrategy-class.html

Intro Article: https://www.softwarearchitekt.at/post/2016/12/02/sticky-routes-in-angular-2-3-with-routereusestrategy.aspx

nativescript-angular's default Implementation of RouteReuseStrategy: https://github.com/NativeScript/nativescript-angular/blob/cb4fd3a/nativescript-angular/router/ns-route-reuse-strategy.ts

aliqandil
  • 1,673
  • 18
  • 28
Corbfon
  • 3,514
  • 1
  • 13
  • 24
  • Thank you Corbfon for a thorough explanation! It helped me understand the RouteReuseStrategy more. My problem was to NOT store specific routes. I found out,that the function shouldDetach was central and just checked for the length of the current route: `shouldDetach(route: ActivatedRouteSnapshot): boolean { if (route.url.length > 1) { return false; //not stored } else { return true; //stored } }`. Would that not be sufficient to solve my problem? – Anders Gram Mygind Jan 09 '17 at 08:49
  • Hey Anders, `shouldDetach` is indeed where you should make the decision about storing your route. The implementation you have above would store only the root path, since that is the path for which the `router.url.length` array = 0. I would pick a path you want to store, or an array of paths you would like to store, and check the path against that array using `forEach` and the `route.routeConfig.path` property. I'll come back and edit my answer to better answer your question. – Corbfon Jan 09 '17 at 21:49
  • @Corbfon have you tried this on child paths? I have posted a question regarding an error I am receiving: http://stackoverflow.com/questions/41584664/cannot-reattach-activatedroutesnapshot-created-from-a-different-route – Tjaart van der Walt Jan 11 '17 at 07:05
  • @Corbfon that's a great explanation. I still struggle to understand how to change shouldReuseRoute in order to keep state of only one specific route. Please can you give an example? – Shahin Jan 11 '17 at 12:04
  • 2
    @shaahin I've added an example, which is the exact code contained in my current implementation! – Corbfon Jan 11 '17 at 20:09
  • @TjaartvanderWalt I'm checking out your question now, and will work on an answer! Never tried it with child routes. Your Plunker is awesome, I'd love to link to it! Could you upload the ReuseRouteStrategy above in place of custom-reuse-strategy.ts and add in the code for `shouldDetach` and the `acceptedRoutes` array from the newly added example? – Corbfon Jan 11 '17 at 20:19
  • @Corbfon I have updated the plunker with your above code. Feel free to link to the plunker, or event fork it into a new plunker. – Tjaart van der Walt Jan 12 '17 at 05:16
  • 1
    @Corbfon I have also opened an issue on the official github page: https://github.com/angular/angular/issues/13869 – Tjaart van der Walt Jan 12 '17 at 05:17
  • 2
    Is there a way to get it to re-run enter animations when re-activating a stored route? – Jinder Sidhu Jan 12 '17 at 17:39
  • 2
    The ReuseRouteStrategy will hand your component back to the router, so it will be in whatever state it was left in. If you'd like the component(s) to _react_ to the attachment, you may use a service that offers an `Observable`. The component should subscribe to the `Observable` during the `ngOnInit` lifecycle hook. Then you will be able to tell the component, from the `ReuseRouteStrategy`, that it has just been attached and the component can modify its state as fit. – Corbfon Jan 12 '17 at 18:10
  • 1
    @AndersGramMygind if my answer provides an answer to the question you proposed, would you mark it as the answer? – Corbfon Jan 13 '17 at 16:34
  • @Corbfon I did not use your answer - I stick with the simple answer I came up with myself - using the method `shouldDetach(route: ActivatedRouteSnapshot): boolean { if (route.url.length > 1) { return false; //not stored } else { return true; //stored } } ` I will though give you credit for a thorough explanation of the class. @shaahin can you use my code to solve your problem? – Anders Gram Mygind Jan 16 '17 at 08:15
  • I got error on your example when navigate to View->Edit->Back->View Error: Cannot reattach ActivatedRouteSnapshot created from a different route https://infinit.io/_/3fNskFR – Свободен Роб Jan 26 '17 at 14:13
  • @СвободенРоб were you looking at the Plunker or running the example code? This is a known issue with child routing (that Plunker was made as an example of that issue). I'm working on a fix, and will update this code when I've got it – Corbfon Jan 26 '17 at 14:20
  • @Corbfon, yes I was running Plunker Demo. I have the same problem as mentioned here: https://stackoverflow.com/questions/39409756/angular-2-route-change-to-same-component-causing-reload Do you have idea how can I solve it ? – Свободен Роб Jan 26 '17 at 15:12
  • 1
    @СвободенРоб yes, we need to find a better "key" to map our strategy off of than `route.routeConfig.path`. Either that, or design a more complex way of handling that logic. The current system allows the router to attempt attaching a parent's child route in place of a different child route, which throws the error. – Corbfon Jan 26 '17 at 15:16
  • How would one use this to scroll to the top if not in the back history? (and properly scroll to anchors? – James Hancock May 23 '17 at 21:52
  • Would you mind being a little more specific about the use case? Maybe write it like a user story? – Corbfon May 24 '17 at 15:59
  • @Corbfon Did you ever figure out how to handle child routes? I've got a fairly complex routing scheme with lazy loaded child routes, and am having a hard time getting the caching to work as expected. – jbgarr Jul 06 '17 at 16:01
  • @jbgarr unfortunately not yet. I haven't had the time at work to dig around in more complex `shouldAttach` and `shouldDetach` logic. I think the Router we're dealing with here is a little more complex than needbe, but essentially someone just needs to write an interface to peel it apart and make a better decision on what to detach and store – Corbfon Jul 06 '17 at 16:53
  • "If you'd like the component(s) to react to the attachment, you may use a service that offers an Observable" @Corbfon I created a service called MessageService and I tried to inject it in my CustomReuseStrategy but I get this error : Can't resolve all parameters for CustomReuseStrategy: (?) and I think that this is because the service is not yet available. What do you think about this ? – Tarida George Aug 31 '17 at 07:13
  • Solved by adding [deps] : [MessageService] to providers child object. – Tarida George Aug 31 '17 at 07:27
  • I just wanted to add a note that this `RouteReuseStrategy` API is marked *Experimental*. It could be removed or see significant breaking changes in a future Angular release. – Splaktar Sep 23 '17 at 17:09
  • @TGeorge did this solve you problem regarding the `Cannot reattach ActivatedRouteSnapshot created from a different route` error? – bluePearl Dec 18 '17 at 00:16
  • @bluePearl I think TGeorge was dealing with a service provider issue. Do you have child routes in your application that you're attempting to detach/re-attach? If so, this strategy has caused issues, as it only checks the url and does not crawl the route tree in order to determine child routing. You'd need to write a more sophisticated handler to detach/store/attach the correct child routes. – Corbfon Dec 19 '17 at 19:08
  • I think there is a typo for shouldReuseRoute() comments: @returns boolean basically indicating true if the user intends to leave the current route. It is the other way around, or did I get it wrong ? It should be "if the user intends to remain on the current route" – Raphael St Mar 19 '20 at 18:19
  • _"AND the component renders, by default, in the same state it was left"_ Would be cool if you could return to the state after `ngOnInit`. – Jeppe Apr 17 '20 at 15:07
  • @Jeppe all you have to do in order to return to that state is NOT mount the component in your mounting logic – Corbfon Apr 28 '20 at 17:04
  • Your solution deserves thumbs up :) – WasiF May 22 '21 at 07:07
  • This doesn't work with a `{path: ''}`. Got really frustrated - then realized can't `set` in `Map` with `''`. – CodeFinity Jul 06 '21 at 00:51
  • Now, the only issue is how to get `params`, for example, `something/:id`. How to get `id` in here and reuse dynamic components? – CodeFinity Jul 06 '21 at 02:07
  • `shouldReuseRoute` return true => if you do not want to kick the reuse route strategy, return false => kick the other methods to understand if we have to store/retrive – albanx Apr 29 '22 at 23:12
59

Don't be intimidated by the accepted answer, this is pretty straightforward. Here's a quick answer what you need. I would recommend at least reading the accepted answer, as it's full of great detail.

This solution doesn't do any parameter comparison like the accepted answer but it will work fine for storing a set of routes.

app.module.ts imports:

import { RouteReuseStrategy } from '@angular/router';
import { CustomReuseStrategy, Routing } from './shared/routing';

@NgModule({
//...
providers: [
    { provide: RouteReuseStrategy, useClass: CustomReuseStrategy },
  ]})

shared/routing.ts:

export class CustomReuseStrategy implements RouteReuseStrategy {
 routesToCache: string[] = ["dashboard"];
 storedRouteHandles = new Map<string, DetachedRouteHandle>();

 // Decides if the route should be stored
 shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return this.routesToCache.indexOf(route.routeConfig.path) > -1;
 }

 //Store the information for the route we're destructing
 store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    this.storedRouteHandles.set(route.routeConfig.path, handle);
 }

//Return true if we have a stored route object for the next route
 shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return this.storedRouteHandles.has(route.routeConfig.path);
 }

 //If we returned true in shouldAttach(), now return the actual route data for restoration
 retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    return this.storedRouteHandles.get(route.routeConfig.path);
 }

 //Reuse the route if we're going to and from the same route
 shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
 }
}
Chris Fremgen
  • 4,649
  • 1
  • 26
  • 26
41

In the addition to the accepted answer (by Corbfon) and Chris Fremgen's shorter and more straightforward explanation, I want to add a more flexible way of handling routes that should use the reuse strategy.

Both answers store the routes we want to cache in an array and then check if the current route path is in the array or not. This check is done in shouldDetach method.

I find this approach inflexible because if we want to change the name of the route we would need to remember to also change the route name in our CustomReuseStrategy class. We may either forget to change it or some other developer in our team may decide to change the route name not even knowing about the existence of RouteReuseStrategy.

Instead of storing the routes we want to cache in an array, we can mark them directly in RouterModule using data object. This way even if we change the route name, the reuse strategy would still be applied.

{
  path: 'route-name-i-can-change',
  component: TestComponent,
  data: {
    reuseRoute: true
  }
}

And then in shouldDetach method we make a use of that.

shouldDetach(route: ActivatedRouteSnapshot): boolean {
  return route.data.reuseRoute === true;
}
Davor
  • 739
  • 10
  • 14
26

Another implementation more valid, complete and reusable. This one supports lazy loaded modules as @Uğur Dinç and integrate @Davor route data flag. The best improvement is the automatic generation of an (almost) unique identifier based on page absolute path. This way you don't have to define it yourself on every page.

Mark any page that you want to cache setting reuseRoute: true. It will be used in shouldDetach method.

{
  path: '',
  component: MyPageComponent,
  data: { reuseRoute: true },
}

This one is the simplest strategy implementation, without comparing query params.

import { ActivatedRouteSnapshot, RouteReuseStrategy, DetachedRouteHandle, UrlSegment } from '@angular/router'

export class CustomReuseStrategy implements RouteReuseStrategy {

  storedHandles: { [key: string]: DetachedRouteHandle } = {};

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return route.data.reuseRoute || false;
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    const id = this.createIdentifier(route);
    if (route.data.reuseRoute) {
      this.storedHandles[id] = handle;
    }
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    const id = this.createIdentifier(route);
    const handle = this.storedHandles[id];
    const canAttach = !!route.routeConfig && !!handle;
    return canAttach;
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    const id = this.createIdentifier(route);
    if (!route.routeConfig || !this.storedHandles[id]) return null;
    return this.storedHandles[id];
  }

  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
  }

  private createIdentifier(route: ActivatedRouteSnapshot) {
    // Build the complete path from the root to the input route
    const segments: UrlSegment[][] = route.pathFromRoot.map(r => r.url);
    const subpaths = ([] as UrlSegment[]).concat(...segments).map(segment => segment.path);
    // Result: ${route_depth}-${path}
    return segments.length + '-' + subpaths.join('/');
  }
}

This one also compare the query params. compareObjects has a little improvement over @Corbfon version: loop through properties of both base and compare objects. Remember that you can use an external and more reliable implementation like lodash isEqual method.

import { ActivatedRouteSnapshot, RouteReuseStrategy, DetachedRouteHandle, UrlSegment } from '@angular/router'

interface RouteStorageObject {
  snapshot: ActivatedRouteSnapshot;
  handle: DetachedRouteHandle;
}

export class CustomReuseStrategy implements RouteReuseStrategy {

  storedRoutes: { [key: string]: RouteStorageObject } = {};

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return route.data.reuseRoute || false;
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    const id = this.createIdentifier(route);
    if (route.data.reuseRoute && id.length > 0) {
      this.storedRoutes[id] = { handle, snapshot: route };
    }
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    const id = this.createIdentifier(route);
    const storedObject = this.storedRoutes[id];
    const canAttach = !!route.routeConfig && !!storedObject;
    if (!canAttach) return false;

    const paramsMatch = this.compareObjects(route.params, storedObject.snapshot.params);
    const queryParamsMatch = this.compareObjects(route.queryParams, storedObject.snapshot.queryParams);

    console.log('deciding to attach...', route, 'does it match?');
    console.log('param comparison:', paramsMatch);
    console.log('query param comparison', queryParamsMatch);
    console.log(storedObject.snapshot, 'return: ', paramsMatch && queryParamsMatch);

    return paramsMatch && queryParamsMatch;
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    const id = this.createIdentifier(route);
    if (!route.routeConfig || !this.storedRoutes[id]) return null;
    return this.storedRoutes[id].handle;
  }

  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
  }

  private createIdentifier(route: ActivatedRouteSnapshot) {
    // Build the complete path from the root to the input route
    const segments: UrlSegment[][] = route.pathFromRoot.map(r => r.url);
    const subpaths = ([] as UrlSegment[]).concat(...segments).map(segment => segment.path);
    // Result: ${route_depth}-${path}
    return segments.length + '-' + subpaths.join('/');
  }

  private compareObjects(base: any, compare: any): boolean {

    // loop through all properties
    for (const baseProperty in { ...base, ...compare }) {

      // determine if comparrison object has that property, if not: return false
      if (compare.hasOwnProperty(baseProperty)) {
        switch (typeof base[baseProperty]) {
          // if one is object and other is not: return false
          // if they are both objects, recursively call this comparison function
          case 'object':
            if (typeof compare[baseProperty] !== 'object' || !this.compareObjects(base[baseProperty], compare[baseProperty])) {
              return false;
            }
            break;
          // if one is function and other is not: return false
          // if both are functions, compare function.toString() results
          case 'function':
            if (typeof compare[baseProperty] !== 'function' || base[baseProperty].toString() !== compare[baseProperty].toString()) {
              return false;
            }
            break;
          // otherwise, see if they are equal using coercive comparison
          default:
            // tslint:disable-next-line triple-equals
            if (base[baseProperty] != compare[baseProperty]) {
              return false;
            }
        }
      } else {
        return false;
      }
    }

    // returns true only after false HAS NOT BEEN returned through all loops
    return true;
  }
}

If you have a best way to generate unique keys comment my answer, I will update the code.

Thank you to all the guys who shared their solution.

McGiogen
  • 654
  • 8
  • 17
  • 4
    This should be the accepted answer. Many solution provided above cannot support multiple pages with same child URL. Because they are comparing the activatedRoute URL, which is not the full path. – zhuhang.jasper Oct 01 '19 at 07:21
  • 1
    Great solution! Just to mention, if you want to delete stored components when the user signs out you could do something like this in the `shouldAttach` hook `if (route.component === AuthComponent ){ this.storedHandles = {}; return false; }` – juan_carlos_yl Jun 14 '21 at 15:38
  • If I have configuration like `{ path: '', component: UsersComponent, children: [ { path: ':id', component: UserComponent, data: { reuseRoute: true } } ] }` in this case *UserComponent* is cached in scenario `/user/1 -> /users -> /user/2` which is expected, but when it will be destroyed? Even if I try to move away from parent *UsersComponent* `/users -> /something-else`, that route is still active. So I just want to understand like when it would be destroyed? – Aakash Goplani Nov 21 '21 at 10:08
18

To use Chris Fremgen's strategy with lazily loaded modules, modify CustomReuseStrategy class to the following:

import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';

export class CustomReuseStrategy implements RouteReuseStrategy {
  routesToCache: string[] = ["company"];
  storedRouteHandles = new Map<string, DetachedRouteHandle>();

  // Decides if the route should be stored
  shouldDetach(route: ActivatedRouteSnapshot): boolean {
     return this.routesToCache.indexOf(route.data["key"]) > -1;
  }

  //Store the information for the route we're destructing
  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
     this.storedRouteHandles.set(route.data["key"], handle);
  }

  //Return true if we have a stored route object for the next route
  shouldAttach(route: ActivatedRouteSnapshot): boolean {
     return this.storedRouteHandles.has(route.data["key"]);
  }

  //If we returned true in shouldAttach(), now return the actual route data for restoration
  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
     return this.storedRouteHandles.get(route.data["key"]);
  }

  //Reuse the route if we're going to and from the same route
  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
     return future.routeConfig === curr.routeConfig;
  }
}

finally, in your feature modules' routing files, define your keys:

{ path: '', component: CompanyComponent, children: [
    {path: '', component: CompanyListComponent, data: {key: "company"}},
    {path: ':companyID', component: CompanyDetailComponent},
]}

More info here.

Uğur Dinç
  • 2,415
  • 1
  • 18
  • 25
  • 1
    Thanks for adding this! I've got to give it a try. It could even fix some of the child route handling issues that my solution runs into. – Corbfon Dec 19 '17 at 19:10
  • I had to use `route.data["key"]` to build without error. But the issue i am having is that i have a route+component that is used in two different places. `1. sample/list/item` and `2. product/id/sample/list/item` when i first load either of the paths it loads fine but the other throws the reattached error because i am storing based on `list/item` So my work around is i duplicated the route and made some change to the url path but displaying the same component. Not sure if there is another work around for that. – bluePearl Dec 22 '17 at 07:47
  • This kind of confused me, the above just wouldn't work, it would blow up as soon as I hit one of my cache routes, (it would no longer navigate and there where errors in the console). Chris Fremgen's solution seems to work fine with my lazy modules as far as I can tell so far... – MIP1983 Sep 12 '19 at 11:03
16

ANGULAR 13 (28/02/2022 VERSION)

after reading alot of guide and suggestion. I can explain this:

firstly,you must understand what are future and curr.

eg: when you navigate from localhost/a to localhost/b and now you are in b.

CASE 1: you want to go from /a -> /b

  • shouldReuseRoute: false becase future !== current.
  • shouldDetach: true because we will save (detach) anything in future to store and waiting to reuse(attach).
  • shouldRetrieve: true || fase check the handler if yes we attach the saved future component to reuse. if no we do nothing. (in this case is no)

Case 2: you want go from /b?id=1 -> /b?id=2

  • shouldReuseRoute: true because future === current;
  • shouldDetach: skip
  • shouldRetrieve: skip

case 3: you want go back from /b -> /a

  • shouldReuseRoute: false becase future !== current.
  • shouldDetach: true because we will save (detach) anything in future to store and waiting to reuse(attach).
  • shouldRetrieve: true || fase check the handler if yes we attach the saved future component to reuse. if no we do nothing. (in this case is yes)

ok. more simple visual from https://stackoverflow.com/a/45788698/5748537

navigate to a
shouldReuseRoute->return true->do nothing

a->b
shouldReuseRoute()->return false->shouldDetach()->return true->store a

then b->a
shouldReuseRoute()->return false->shouldDetach()->return true->store b->retrieve() return a ->attach() a.

then much more visual from https://stackoverflow.com/a/69004775/5748537 enter image description here

and final the correct code from angular team: https://github.com/angular/angular/issues/44383

export class CustomRouteReuseStrategy implements RouteReuseStrategy { 
    private handlers: Map<Route, DetachedRouteHandle> = new Map();

    constructor() {}

    public shouldDetach(_route: ActivatedRouteSnapshot): boolean {
        return true;
    }

    public store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle
    ): void {
        if (!route.routeConfig) return;
        this.handlers.set(route.routeConfig, handle);
    }

    public shouldAttach(route: ActivatedRouteSnapshot): boolean {
        return !!route.routeConfig && !!this.handlers.get(route.routeConfig);
    }

    public retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null {
        if (!route.routeConfig || !this.handlers.has(route.routeConfig)) return null;
        return this.handlers.get(route.routeConfig)!;
    }

    public shouldReuseRoute(
        future: ActivatedRouteSnapshot,
        curr: ActivatedRouteSnapshot
    ): boolean {
        return future.routeConfig === curr.routeConfig;
    }
}
Hiep Tran
  • 3,735
  • 1
  • 21
  • 29
6

All mentioned solutions were somehow insufficient in our case. We have smaller business app with:

  1. Introduction page
  2. Login page
  3. App (after login)

Our requirements:

  1. Lazy-loaded modules
  2. Multi-level routes
  3. Store all router / component states in memory in app section
  4. Option to use default angular reuse strategy on specific routes
  5. Destroying all components stored in memory on logout

Simplified example of our routes:

const routes: Routes = [{
    path: '',
    children: [
        {
            path: '',
            canActivate: [CanActivate],
            loadChildren: () => import('./modules/dashboard/dashboard.module').then(module => module.DashboardModule)
        },
        {
            path: 'companies',
            canActivate: [CanActivate],
            loadChildren: () => import('./modules/company/company.module').then(module => module.CompanyModule)
        }
    ]
},
{
    path: 'login',
    loadChildren: () => import('./modules/login/login.module').then(module => module.LoginModule),
    data: {
        defaultReuseStrategy: true, // Ignore our custom route strategy
        resetReuseStrategy: true // Logout redirect user to login and all data are destroyed
    }
}];

Reuse strategy:

export class AppReuseStrategy implements RouteReuseStrategy {

private handles: Map<string, DetachedRouteHandle> = new Map();

// Asks if a snapshot from the current routing can be used for the future routing.
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
}

// Asks if a snapshot for the current route already has been stored.
// Return true, if handles map contains the right snapshot and the router should re-attach this snapshot to the routing.
public shouldAttach(route: ActivatedRouteSnapshot): boolean {
    if (this.shouldResetReuseStrategy(route)) {
        this.deactivateAllHandles();
        return false;
    }

    if (this.shouldIgnoreReuseStrategy(route)) {
        return false;
    }

    return this.handles.has(this.getKey(route));
}

// Load the snapshot from storage. It's only called, if the shouldAttach-method returned true.
public retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    return this.handles.get(this.getKey(route)) || null;
}

// Asks if the snapshot should be detached from the router.
// That means that the router will no longer handle this snapshot after it has been stored by calling the store-method.
public shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return !this.shouldIgnoreReuseStrategy(route);
}

// After the router has asked by using the shouldDetach-method and it returned true, the store-method is called (not immediately but some time later).
public store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
    if (!handle) {
        return;
    }

    this.handles.set(this.getKey(route), handle);
}

private shouldResetReuseStrategy(route: ActivatedRouteSnapshot): boolean {
    let snapshot: ActivatedRouteSnapshot = route;

    while (snapshot.children && snapshot.children.length) {
        snapshot = snapshot.children[0];
    }

    return snapshot.data && snapshot.data.resetReuseStrategy;
}

private shouldIgnoreReuseStrategy(route: ActivatedRouteSnapshot): boolean {
    return route.data && route.data.defaultReuseStrategy;
}

private deactivateAllHandles(): void {
    this.handles.forEach((handle: DetachedRouteHandle) => this.destroyComponent(handle));
    this.handles.clear();
}

private destroyComponent(handle: DetachedRouteHandle): void {
    const componentRef: ComponentRef<any> = handle['componentRef'];

    if (componentRef) {
        componentRef.destroy();
    }
}

private getKey(route: ActivatedRouteSnapshot): string {
    return route.pathFromRoot
        .map((snapshot: ActivatedRouteSnapshot) => snapshot.routeConfig ? snapshot.routeConfig.path : '')
        .filter((path: string) => path.length > 0)
        .join('');
    }
}
hovado
  • 4,474
  • 1
  • 24
  • 30
3

the following is work! reference:https://www.cnblogs.com/lovesangel/p/7853364.html

import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';

export class CustomReuseStrategy implements RouteReuseStrategy {

    public static handlers: { [key: string]: DetachedRouteHandle } = {}

    private static waitDelete: string

    public static deleteRouteSnapshot(name: string): void {
        if (CustomReuseStrategy.handlers[name]) {
            delete CustomReuseStrategy.handlers[name];
        } else {
            CustomReuseStrategy.waitDelete = name;
        }
    }
   
    public shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return true;
    }

   
    public store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
        if (CustomReuseStrategy.waitDelete && CustomReuseStrategy.waitDelete == this.getRouteUrl(route)) {
            // 如果待删除是当前路由则不存储快照
            CustomReuseStrategy.waitDelete = null
            return;
        }
        CustomReuseStrategy.handlers[this.getRouteUrl(route)] = handle
    }

    
    public shouldAttach(route: ActivatedRouteSnapshot): boolean {
        return !!CustomReuseStrategy.handlers[this.getRouteUrl(route)]
    }

    /** 从缓存中获取快照,若无则返回nul */
    public retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
        if (!route.routeConfig) {
            return null
        }

        return CustomReuseStrategy.handlers[this.getRouteUrl(route)]
    }

   
    public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig &&
            JSON.stringify(future.params) === JSON.stringify(curr.params);
    }

    private getRouteUrl(route: ActivatedRouteSnapshot) {
        return route['_routerState'].url.replace(/\//g, '_')
    }
}
红兵伍
  • 39
  • 4
1

I faced with these issues implementing a custom route reuse strategy:

  1. Perform operations on a route attach/dettach: manage subscriptions, cleanup, etc.;
  2. Preserve only the last parameterised route's state: memory optimisation;
  3. Reuse a component, not a state: manage the state with a state-managment tools.
  4. "Cannot reattach ActivatedRouteSnapshot created from a different route" error;

So I wrote a library solving these issues. The library provides a service and decorators for attach/detach hooks and uses a route's components to store detached routes, not a route's paths.

Example:

/* Usage with decorators */
@onAttach()
public onAttach(): void {
  // your code...
}

@onDetach()
public onDetach(): void {
  // your code...
}

/* Usage with a service */
public ngOnInit(): void {
  this.cacheRouteReuse
    .onAttach(HomeComponent) // or any route's component
    .subscribe(component => {
      // your code...
    });

  this.cacheRouteReuse
    .onDetach(HomeComponent) // or any route's component
    .subscribe(component => {
      // your code...
    });
}

The library: https://www.npmjs.com/package/ng-cache-route-reuse

Stas Amasev
  • 399
  • 2
  • 3
  • 9
  • 1
    Just linking to your own library or tutorial is not a good answer. Linking to it, explaining why it solves the problem, providing code on how to do so and disclaiming that you wrote it makes for a better answer. See: [**What signifies “Good” self promotion?**](//meta.stackexchange.com/q/182212) – Paul Roub Jun 02 '20 at 20:06
0

All the above answers are great but none of them will work properly if you have lazy-load router and that too nested ones.

To overcome that, shouldReuseRoute and path to compare the routes needs to be changed:

Path A: abc/xyx/3
Path B: abc/rty/8

<abc>
 <router-outlet></router-outlet>
</abc>

/* If we move from pathA to pathB or vice versa, 
 * then  `routeConfig` will be same since we are routing within the same abc, 
 * but to store the handle properly, you should store fullPath as key.
*/

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
  ): boolean {
  
    return future.routeConfig === curr.routeConfig;
  }


  private getPathFromRoot(route: ActivatedRouteSnapshot) {
    return (route["_urlSegment"]["segments"] as UrlSegment[])
      .map((seg) => seg.path)
      .join("/");
  }

Shadab Faiz
  • 2,380
  • 1
  • 18
  • 28