Aurelia: [WIP] Route Configuration API Proposal [RFC]

Created on 13 Sep 2018  路  11Comments  路  Source: aurelia/aurelia

  • [x] IRouteConfig interface
  • [x] Basic routes
  • [x] Canonical urls
  • [x] Aliased routes
  • [x] Custom viewports
  • [x] Child routes
  • [x] Absolute paths
  • [ ] Case sensitive routes
  • [x] Parameterized routes
  • [x] Custom parameterizers
  • [ ] Navigation model
  • [x] Route metadata

Please let me know if there are any use cases that are missing from this specification.

The Route Configuration API

Route configuration can be done one of three ways: Up-front (no definition yet), using the @route decorator, or by defining a static routes property. For clarity, I've included the most terse definition with the decorator approach and the most verbose definition with the static routes approach for each example. This will demonstrate what they will look like in a typical development case as well as an explicit definition what they imply. I've also commented out implicit or conventional values that will be set by Aurelia under the hood.

IRouteConfig

The names of properties are up for grabs. I have chosen names that I believe are most illuminating and most standard.

interface IRouteConfig {

  // A string or array of strings of matching route paths.
  // (Renamed from route in vCurrent)
  path: string | string[],

  // A constructor function for the view-model to attach for this route. 
  // For route decorator and static routes approaches, Aurelia will set this
  // value under the hood. 
  // (Repurposed from moduleId in vCurrent)
  component?: IComponent,

  // A uniquely identifiable name for the route, for canonical navigation.
  // For route decorator and static routes approaches, Aurelia will try to
  // set this value by convention if not specified explicitly.
  name?: string,

  // Optional, the name of the viewport to attach the controller to. If not
  // specified, the default viewport will be used.
  viewport?: string,

  // Optional, the name of the parent route or routes, matched by the `name` property.
  parent?: string | string[],

  // Optional, flag to opt the route out of the navigation model. Defaults 
  // to true.
  nav?: boolean,

  // Optional, an object with additional information available to the 
  // view-model throughout the activation lifecycle. 
  // (Renamed from settings in vCurrent)
  meta?: any
}

A basic route

Defining a basic route is dead simple.

@route('home')
export class MyViewModel {
  static routes = [
    { 
      path: 'home',
      //, name: 'my'
      //, component: this
    }
  ];
}

Canonical urls

Multiple routes can redirect to the same route, creating a canonical url for that page.

@route('home')
@route({ path: '', redirect: 'home' })
export class MyViewModel {
  static routes = [
    {
      path: 'home'.
      //, name: 'my'
      //, component: this
    },
    {
      path: '',
      redirect: 'home'
    }
  ]  
}

Aliased routes

Alternatively, multiple routes can load the same page without redirecting the route.

@route(['', 'home'])
export class MyViewModel {
  static routes = [
    {
      path: ['', 'home'],
      //, name: 'my'
      //, component: this
    }
  ]
}

Custom viewports

If a page has multiple viewports defined, each route should specify which viewport the route targets. Multiple route configurations can target the same paths.

<template>
  <viewport name="left"></viewport>
  <viewport name="right"></viewport>
</template>
@route({ path: 'forwards', viewport: 'left' })
@route({ path: 'backwards', viewport: 'right' })
export class FirstViewModel {
  static routes = [
    {
      path: 'forwards',
      viewport: 'left',
      //, name: 'first'
      //, component: this
    }, 
    {
      path: 'backwards',
      viewport: 'right'
      //, name: 'first'
      //, component: this
    }
  ]
}

@route({ path: 'forwards', viewport: 'right' })
@route({ path: 'backwards', viewport: 'left' })
export class LastViewModel {
  static routes = [
    {
      path: 'forwards',
      viewport: 'right',
      //, name: 'last'
      //, component: this
    }, 
    {
      path: 'backwards',
      viewport: 'left'
      //, name: 'last'
      //, component: this
    }
  ]
}

If two configurations target the same paths and viewports, a warning should be raised.

@route('home')
export class HomeViewModel { }

@route('home') // Warning! Duplicate route definition for path "home" in viewport "default".
@route({ path: 'home', viewport: 'side' }) // OK.
export class NotHomeViewModel { }

Child routes

NOTE I've done my best to get various points of views concerning the importance of child routes. I'm still looking for use cases that may not be covered here.

A route can be defined as a child route by specifying its parent by name. This allows (a) creating a child viewport that is refreshed without touching any other viewports and (b) defining a more flexible navigation model (below) without having to maintain a separate route configuration table.

// 'parent' loads parent only into the default viewport
@route({ path: 'parent' }) 
export class ParentViewModel { }
<template id="parent">
  <viewport></viewport>
</template>
// 'child' loads child only into the default viewport
@route({ path: 'child' })

// 'parent/child' loads child into the default viewport of parent
@route({ path: 'child', parent: 'parent' })
export class ChildViewModel {
  static routes = [
    {
      path: 'child',
      //, name: 'child'
      //, component: this
    },
    {
      path: 'child',
      parent: 'parent',
      //, component: this
    }
  ]
}

Absolute paths (aka alias via Vue)

Paths defined with a leading forward slash match the app's base url. This is particularly useful when you want a child route to match a path independent of its parent's path.

// 'special-child' loads child into the default viewport of parent
@route({ path: '/special-child', parent: 'parent' })
export class ChildViewModel {
  static routes = [
    {
      path: '/special-child',
      parent: 'parent',
      //, component: this
    }
  ]
}

Parameterized routes

Parameterized routes define dynamic sections of the path (parameters) that are made available throughout the activation lifecycle. If a type is specified, the route will only match values that can be coerced to the specified type, and the value will be available as the specified type throughout the activation lifecycle.

// matches 'foo/bar1!@#$%'
@route('foo/{name}')

// matches 'foo/123' and 'foo' but not 'foo/bar'
@route('foo/{id?:number}')
export class MyViewModel {
  static routes = [
    {
      path: 'foo/{name: string}',
      //, component: this
    },
    {
      path: 'foo/{id?: number}',
      //, component: this
    }
  ];
}

Custom parameterizers

In addition to native JavaScript types, custom types can be registered with the router.

router.registerParameterizer('Guid', (param: string) => {
  if (/^[0-9a-f]{8}-?(?:[0-9a-f]{4}-?){3}[0-9a-f]{12}$/i.test(param)) {
    return param;
  }
  // If no value is returned, the route does not match.
});

// matches 'foo/6e380650-8b50-4cb4-8a09-a5449abf597b' but not 'foo/123'
@route('foo/{id: Guid}')
export class FooViewModel { }

Navigation model

Route metadata

Special metadata can be specified on each route that is made available throughout the activation lifecycle. This is particularly useful for defining implicit or hidden parameters when a user navigates through a particular route.

@route('')
@route({ path: 'other', meta: { referral: true })
export class MyViewModel {
  static routes = [
    {
      path: '',
      //, component: this
    },
    {
      path: 'other',
      meta: { referral: true },
      //, component: this
    }
  ]

  activate(params, config) {
    config.meta.referral === true;
  }
}
API Docs In discussion Needs more feedback Needs more info RFC Router v1

Most helpful comment

As @EisenbergEffect said, <compose> now support slots, which is going to check all the boxes that layouts previously checked.

To enhance his example a bit: Imagine that you have two routes that represent the same view with a different layout. You can add this extra data to the meta property (formerly settings) and handle that directly in your view via a compose element.

foo.ts

@route({ path: 'x', meta: { layout: 'layouts/horizontal' } })
@route({ path: 'y', meta: { layout: 'layouts/vertical' } })
class FooComponent {
  activate(params, meta) {
    this.someLayoutComponent = () => import(meta.layout);
  }
}

Currently, the layout feature is part of the router but has distinct logic. There are two problems with this. From the ivory tower, layouts have nothing to do with routes. The router is designed to interface between Aurelia and the browser's URL bar. If layouts do belong, they don't really belong in the router. From the ground, it is extra complexity, extra logic, and extra code to maintain.

Layouts behave identically to compose components, and the existing meta and <compose> with slot features can achieve the same result.

@freshcutdevelopment I'd really love to hear a strong defense of the layout feature. I've never understood what problems it solved that couldn't be solved before.

All 11 comments

Very nice and appealing declaration. One thing that could be hard with this per child declaration is the ability to handle unknown route. What options do we have if we go for this path ?

I like the direction this is heading. Here are a few quick thoughts:

  1. Instead of introducing a new custom element viewport, I'd like everything to work through the core compose element, if it can. So, what about a custom attribute approach, like this?
<template>
  <compose viewport="left"></compose>
  <compose viewport="right"></compose>
</template>
  1. For parameterized routes, could we match the TypeScript syntax for optional parameters? I think this would make things more consistent. Notice the placement of ?.
@route('foo/{id?:number}')
  1. When it comes to the route configuration, "controller" is a bit of an odd word, given how vNext works. There's no such thing as a controller. Literally, there are only components and views (referred to as "renderables"). The compose element can handle a component constructor, a view or anything that can create a view (such as an IViewFactory). So, for view-models, those are really treated as components. In JIT mode, they will need to be decorated with the customElement decorator and have their view specified. In AOT mode, the compiler will evaluate conventions and auto-generate the decorators with the view hookups (making the dev experience just like today).

  2. I'm a little confused about how child routes will work, based on the explanation above. Can you help me understand that better?

  3. Have you thought about what you might be able to do with AOT? For example, could you search the code for all @route decorators and generate the route config file ahead of time? This could allow the routes themselves to be lazy-loaded while still enabling the router to have all information up front.

  4. Will there be child router instances or just one central router that handles everything? If you think it's possible to have a single router with child routes rather than a bunch of child routers, I believe that's preferable.

  1. Instead of introducing a new custom element viewport, I'd like everything to work through the core compose element, if it can. So, what about a custom attribute approach, like this?
<template>
  <compose viewport="left"></compose>
  <compose viewport="right"></compose>
</template>
  1. My thought for having a <viewport> element is that it is clear and declarative. It says what it does and does what you'd expect. I agree that under the hood it should just be syntactic sugar over a <compose> element.

Do you think there is an advantage to using not just the same component but also the same API?

  1. Yes.

  2. controller > component

  3. The route configuration allows specifying a parent by name. Consider:

@route({ path: 'employee', redirect: 'employee-new' })
@route({ path: 'employee/0', name: 'employee-new' })
@route({ path: 'employee/{empId: number}', name: 'employee-edit' })
export class EmployeeEditComponent { } 

// these are available child routes on any of the above routes
@route({ path: 'resume', parent: ['employee-edit', 'employee-new'], redirect; 'resume-new' })
@route({ path: 'resume/0', name: 'resume-new', parent: ['employee-edit', 'employee-new'] })
@route({ path: 'resume/{resId: number}', name: 'resume-edit', parent: ['employee-edit', 'employee-new'] })
export class ResumeEditComponent { } 

Navigating to employee/1/resume (1) redirects to employee/1/resume/0, (2) loads EmployeeEditComponent into the default viewport, (3) loads ResumeEditComponent into the default viewport in the EmployeeEditView.

  1. Yes. That's one of the unchecked todos.

  2. I'm not sure if it is possible. I'm just dreaming up what is preferable and ideal here. I agree, child routes are great but child routers are a maintenance nightmare.

This looks like it's really starting to shape up. One thing I can't see in this configuration proposal is support for layouts which we have in vCurrent:

For example: layoutViewModel: PLATFORM.moduleName('layout');

Is there a plan at this stage to port the router's layout functionality into the vNext configuration as well?

I'm wondering if there's a way to do it with the normal primitives in vNext. For example, the compose element dynamically creates elements and can project content into slots. So, maybe we can manage something like this:

<compose subject.bind="someLayoutComponent">
  <compose viewport="left" slot="sidebar"></compose>
  <compose viewport="right" slot="main"></compose>
</compose>

The idea is that someLayoutComponent produces a custom element that defines the layout with slots for the sidebar and main areas. Then the other two compose elements pipe the router's left viewport into the sidebar slot and the right viewport into the main slot.

I'm not sure if we can make it work this way or not yet, but it's worth thinking about before adding a router-specific feature for layout I think.

As @EisenbergEffect said, <compose> now support slots, which is going to check all the boxes that layouts previously checked.

To enhance his example a bit: Imagine that you have two routes that represent the same view with a different layout. You can add this extra data to the meta property (formerly settings) and handle that directly in your view via a compose element.

foo.ts

@route({ path: 'x', meta: { layout: 'layouts/horizontal' } })
@route({ path: 'y', meta: { layout: 'layouts/vertical' } })
class FooComponent {
  activate(params, meta) {
    this.someLayoutComponent = () => import(meta.layout);
  }
}

Currently, the layout feature is part of the router but has distinct logic. There are two problems with this. From the ivory tower, layouts have nothing to do with routes. The router is designed to interface between Aurelia and the browser's URL bar. If layouts do belong, they don't really belong in the router. From the ground, it is extra complexity, extra logic, and extra code to maintain.

Layouts behave identically to compose components, and the existing meta and <compose> with slot features can achieve the same result.

@freshcutdevelopment I'd really love to hear a strong defense of the layout feature. I've never understood what problems it solved that couldn't be solved before.

@davismj @jods4 is a user of layout I believe. Would be nice if @jods4 could also input something too

Thanks everyone for the input, I think I see where you're going with this, and can definitely see the benefit in reducing the concept count if this is no longer required.

@davismj , to give you some context, what I typically use layouts for is to have a separate 'layout' for the login page (where I don't need a nav) as compared to the regular application pages (which would have a nav). Other things I might include in the main layout would be things like breadcrumbs etc.

So this ends up looking like:

<!-- app.html -->
<router-view></router-view>
<!-- login-layout.html-->
<slot></slot>
<!-- main-layout.html-->


md5-12f590c052df2dd7f07d091124503799


<slot></slot>

The way layouts currently work feels very similar to the way ASP.NET MVC Razor layouts work, which is likely what attracted me to them.

If we can achieve this behavior dynamically using <compose> in vNext then awesome 馃憤

@davismj

This looks pretty neat, just some quick thoughts, Is it possible to have access to the children routes of a parent route in vNext routing mechanism, and how does authorization steps will fit in?

And on thing more, I just saw au-viewport, viewport, router-view and compose , in routing all looks to do the same job, am I right? Will they in the future become one thing at least in routing module?

I think we've settled on <au-viewport>, which is just a compose that is controlled by the router.

Sorry to provide feedback this late, but here are my thoughts on the proposal.

Pro

The decorator based API for specifying routes looks very elegant, easy to understand, as well as familiar (referring to ASP.NET WebApi) 馃憤 Same goes for route parameter constraints.

Potential problem

Apart from that, I would like to point out a potential problem with the proposal of specifying the child routes. I will try to explain.

In vCurrent, the child routes are typically defined in child/index vm, without any explicit knowledge of the invoking parent. The parent route configuration comes from one level higher (typically from similar parent/index vm), with the path fragment defined for the parent.

This disassociation lets us put the complete child module (routes, vms, views, etc.) in a different node package altogether. And the child module doesn't need to know what the parent route is. The "main" app can then register this child module as a plugin and act as a hosting app that hosts multiple child modules. The main app can flexibly decide under which parent path fragment it wants host a particular child module. IMO this reduces code duplication, and provides better separation of concerns.

I can imagine that the AOT compiler can process such metadata (as proposed) to create the routing table. However, my first impression after seeing the child route specification was "why the child vm need to know about parent vm?"

Do you think this is a valid concern? Are there more robust alternative solutions for this scenario?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

fkleuver picture fkleuver  路  3Comments

HamedFathi picture HamedFathi  路  13Comments

LarsKemmann picture LarsKemmann  路  11Comments

EisenbergEffect picture EisenbergEffect  路  10Comments

HamedFathi picture HamedFathi  路  5Comments