Search code examples
angularangular-routingangular-routerlink

Why RouterLink adds the input to the end of current URL in bracket


Lets say my url is: http://localhost:4200/user_id/home. Here is my button code:

  <ion-button [routerLink]="['some_user_id', 'payments']" routerLinkActive="selected">
    <ion-label class="label">Payments</ion-label>
  </ion-button>

Because I was getting Error: Cannot match any routes. I started to investigate the problem and I come to, that routerLink is generating such DOM element:

<a href="user_id/home/(some_user_id/payments)" class="button-native" part="native">

When (in the same component) I use router to navigate, like:

this.router.navigate('some_user_id', 'payments'])

all works.

What is the problem that generated href isn't just <a href="some_user_id/payments" class="button-native" part="native"> as allways?


Solution

  • It's because routerLink is a directive and it does a few other things behind the scenes.

    Let's see what happens when you click on an element which has the RouterLink directive:

    @Directive({selector: ':not(a):not(area)[routerLink]'})
    export class RouterLink {
      /* ... */
    
      @HostListener('click')
      onClick(): boolean {
        const extras = {
          skipLocationChange: attrBoolValue(this.skipLocationChange),
          replaceUrl: attrBoolValue(this.replaceUrl),
          state: this.state,
        };
        this.router.navigateByUrl(this.urlTree, extras);
        return true;
      }
    
      get urlTree(): UrlTree {
        return this.router.createUrlTree(this.commands, {
          relativeTo: this.route, // !
          queryParams: this.queryParams,
          fragment: this.fragment,
          preserveQueryParams: attrBoolValue(this.preserve),
          queryParamsHandling: this.queryParamsHandling,
          preserveFragment: attrBoolValue(this.preserveFragment),
        });
      }
    
      /* ... */
    }
    

    Keep an eye on relativeTo: this.route, where this.route points to the current ActivatedRoute(e.g the one associated with /home).

    What Router.createUrlTree does is to apply a set of commands to the current URL tree, which will result in a new URL tree. In your case, the commands are ['some_user_id', 'payments'].

    createUrlTree(commands: any[], navigationExtras: NavigationExtras = {}): UrlTree {
      const {
        relativeTo,
        queryParams,
        fragment,
        preserveQueryParams,
        queryParamsHandling,
        preserveFragment
      } = navigationExtras;
      
      /* .... */
    
      const a = relativeTo || this.routerState.root;
      const f = preserveFragment ? this.currentUrlTree.fragment : fragment;
      let q: Params|null = null;
      
      /* ... resolving query params based on the `queryParamsHandling` strategy */
    
      return createUrlTree(a, this.currentUrlTree, commands, q!, f!);
    }
    

    createUrlTree is where the magic happens:

    export function createUrlTree(
        route: ActivatedRoute, urlTree: UrlTree, commands: any[], queryParams: Params,
        fragment: string): UrlTree {
      // `route` - that one which corresponds to `/home`
      // `commends` - `['some_user_id', 'payments']`
      // `urlTree` - a tree of UrlSegmentGroups, we'll have a closer look a bit later
    
      if (commands.length === 0) { /* Not our case */ }
    
      /* 
      a command might also be one of these objects: 
        * { outlets: { outletName: path } }
        * { k1: v1, k2: v2 } - segment parameters
        * { segmentPath: path }
    
        but in this case, it will simply be a Navigation object {
          isAbsolute: false,
          numberOfDoubleDots: 0,
          commands: ['some_user_id', 'payments']
        }
      */
      const nav = computeNavigation(commands);
    
      if (nav.toRoot()) { 
        /* Not our case;  */ 
        /* It would've been if: this.isAbsolute && this.commands.length === 1 && this.commands[0] == '/' */
      }
    
      /* 
      We'd get a new `Position` object: `return new Position(g, false, ci - dd);`
      where `dd` - number of double dots = 0 and `ci` - current index = 1
      why is it 1? - https://github.com/angular/angular/blob/master/packages/router/src/create_url_tree.ts#L160
      */
      const startingPosition = findStartingPosition(nav, urlTree, route);
    
      const segmentGroup = startingPosition.processChildren ?
          updateSegmentGroupChildren(
              startingPosition.segmentGroup, startingPosition.index, nav.commands) :
          updateSegmentGroup(startingPosition.segmentGroup, startingPosition.index, nav.commands);
      return tree(startingPosition.segmentGroup, segmentGroup, urlTree, queryParams, fragment);
    }
    

    segmentGroup would be the result of updateSegmentGroup. It will eventually reach createNewSegmentGroup:

    function createNewSegmentGroup(
        segmentGroup: UrlSegmentGroup, startIndex: number, commands: any[]): UrlSegmentGroup {
      // Everything before the `startIndex`
      const paths = segmentGroup.segments.slice(0, startIndex); 
    
      let i = 0;
      while (i < commands.length) {
        if (typeof commands[i] === 'object' && commands[i].outlets !== undefined) {
          /* Not our case */
        }
    
        // if we start with an object literal, we need to reuse the path part from the segment
        // That's why the `modifier` is 1 if there are no parameters: https://github.com/angular/angular/blob/master/packages/router/src/create_url_tree.ts#L160
        if (i === 0 && isMatrixParams(commands[0])) {
          const p = segmentGroup.segments[startIndex];
          paths.push(new UrlSegment(p.path, commands[0]));
          i++;
          continue;
        }
    
        const curr = getPath(commands[i]);
        const next = (i < commands.length - 1) ? commands[i + 1] : null;
        if (curr && next && isMatrixParams(next)) {
          paths.push(new UrlSegment(curr, stringify(next)));
          i += 2;
        } else {
    
          // Adding the commands(`['some_user_id', 'payments']`) the the previous segments
          // Which explains why you're getting the current behavior
          paths.push(new UrlSegment(curr, {}));
          i++;
        }
      }
      return new UrlSegmentGroup(paths, {});
    }
    

    Note: this walk-through is based on this ng-run demo.


    An URL can have this structure: segment?queryParams#fragment.

    An UrlSegmentGroup can have an array of UrlSegments and an object of child UrlSegmentGroups:

    export class UrlSegmentGroup {
      /* ... */
    
      parent: UrlSegmentGroup|null = null;
    
      constructor(
          public segments: UrlSegment[],
          public children: {[key: string]: UrlSegmentGroup}) {
        forEach(children, (v: any, k: any) => v.parent = this);
      }
    
      /* ... */
    }
    

    For example, we might have a more complex URL, such as foo/123/(a//named:b). The resulted UrlSegmentGroup will be this:

    {
      segments: [], // The root UrlSegmentGroup never has any segments
      children: {
        primary: {
          segments: [{ path: 'foo', parameters: {} }, { path: '123', parameters: {} }],
          children: {
            primary: { segments: [{ path: 'a', parameters: {} }], children: {} },
            named: { segments: [{ path: 'b', parameters: {} }], children: {} },
          },
        },
      },
    }
    

    which would match a route configuration like this:

    {
      {
        path: 'foo/:id',
        loadChildren: () => import('./foo/foo.module').then(m => m.FooModule)
      },
    
      // foo.module.ts
      {
        path: 'a',
        component: AComponent,
      },
      {
        path: 'b',
        component: BComponent,
        outlet: 'named',
      },
    }
    

    You can experiment with this example in this StackBlitz.

    As you can see, UrlSegmentGroup's children are delimited by (). The names of these children are the router outlet.

    In /(a//named:b), because it uses a / before (, a will be segment of the primary outlet. // is the separator for router outlets. Finally, named:b follows this structure: outletName:segmentPath.

    Another thing that should be mentioned is the UrlSegment's parameters property. Besides positional parameters(e.g foo/:a/:b), segments can have parameters declared like this: segment/path;k1=v1;k2=v2;

    So, an UrlTree has 3 important properties: the root UrlSegmentGroup, the queryParams object and the fragment of the issued URL.


    this.router.navigate('some_user_id', 'payments']) works because Router.navigate will eventually call Router.createUrlTree:

    navigate(commands: any[], extras: NavigationExtras = {skipLocationChange: false}):
        Promise<boolean> {
      validateCommands(commands);
      return this.navigateByUrl(this.createUrlTree(commands, extras), extras);
    }
    

    Then, const a = relativeTo || this.routerState.root; will be reached inside Router.createUrlTree and since there is no relativeTo(as opposed to RouterLink), it will be relative to the root ActivatedRoute.

    You can get the same behavior with routerLink, by adding / at the beginning of the first command: [routerLink]="['/some_user_id', 'payments']"