aurelia / aurelia

Aurelia 2, a standards-based, front-end framework designed for high-performing, ambitious applications.
MIT License
1.39k stars 150 forks source link

Route Registration [RFC] #424

Closed davismj closed 1 year ago

davismj commented 5 years ago

Route Registration

In this proposal, I am going to talk about how to register a route with the router. I'm going to start with the most explicit API and then peel away the defaults and conventions one-by-one, ending with purely conventional routing.

The Route Table

The Router will have an instance of the RouteTable class. The RouteTable class will have a reference to the list of all configured routes. The Router should read from the RouteTable dynamically; if a new route is registered or removed dynamically, it should be immediately available to the Router.

Definition

class RouteTable {

  // adds a new route to the RouteTable, making it immediately available to the router
  public add(route: IRouteConfig): void;
  public add(routes: Array<IRouteConfig>): void;

  // hides routes matching the filter from the Router
  public disable(filter: (IRouteConfig) => boolean): void;

  // exposes routes matching the filter to the Router
  public enable(filter: (IRouteConfig) => boolean): void;
}

Manual Registration

Many developers may find it more manageable to have all of their route definitions separate from the rest of the code and in a single location. The vCurrent analog for this pattern is config.map() in configureRouter(). Having a RouteTable class that can be injected anywhere will maintain the vCurrent behavior while opening up lots of new opportunities both internally and at an API level.

Basic Example

To add a single route, obtain the RouteTable and call add(IRouteConfig).

const table = container.get(RouteTable);
table.add({
  path: 'home',
  component: HomeRoute,
  name: 'home'
});

Common Use Case

src/routes.ts

@inject(RouteTable)
export function configure(table: RouteTable) {
  table.add([
    { path: '', redirect: 'enroll' },
    { path: 'user/profile', name: 'profile', component: ProfileRoute },
    { path: 'user/enroll', name: 'enroll', component: EnrollmentRoute },
    { path: 'site/register', component: RegisterRoute },
    { path: 'site/update', component: UpdateRoute },
    { path: 'site/deactivate', component: DeactivateRoute },
    { path: 'site/enroll', component: EnrollRoute },
    { path: 'app/map', component: EasyterritoryRoute },
    { path: 'app/reporting/:report?', name: 'reporting', component: ReportingRoute },
    { path: 'app/upload', component: AutomationRoute },
    { path: 'app/upload/data/:id', name: 'ahlDataUpload', component: DataRoute },
    { path: 'app/docs/maps', component: MapDocsRoute },
    { path: 'app/docs/forms', component: FormsDocsRoute },
    { path: 'admin/user', component: UserRoute },
    { path: 'admin/company', component: CompanyRoute },
    { path: 'approved', component: ApprovedRoute }
  ]);
}

AOT mode will automatically generate src/routes.ts.

Static Property-based Registration

For many developers, having route definitions in the same file as their corresponding component is more straightforward and manageable. Adding a static routes property to a class and loading it as a resource will automatically register that component on the RouteTable.

Usage

Adding the IRouteConfig to the static routes array will cause the component to be registered against that route when it is loaded as a resource.

export class HomeRoute {
  static routes = [
    { path: '', name: 'home', meta: { ROUTE_PERMISSIONS.Guest } }
  ]
}

AOT mode will use this property to generate the src/routes.ts.

Decorator-based Registration

Decorator syntax is friendly and familiar to many developers, especially those coming from non-JavaScript backgrounds. The syntax is identical to the static property syntax. The decorator itself does nothing more than add the passed IRouteConfig to the static routes array.

Usage

@route({ path: '', name: 'home', meta: { ROUTE_PERMISSIONS.Guest } })
export class HomeRoute { }

Class Name Convention-based Registration

By following a simple, understandable class name convention, developers can skip the manual route definition.

Definition

A class with the name {{Name}}Route will automatically be registered with the route table with the following configuration.

{
  path: '{{name}}/{id?}',
  name: '{{name}}'
}

This behavior can be overridden by manually defining a route configuration using either the static property or the decorator method.

// This class will match 'home' and 'home/123'
export class HomeRoute { }

// This class will match 'home', but will not match 'home/123'
export class HomeRoute { 
  static routes: [{ path: 'home' }]
}
akircher commented 5 years ago

I like it. Some initial thoughts:

How are query parameters passed to the component? What if a path parameter and query parameter share a name? Does one override or do you pass an array of both values?

I like that the syntax looks more familiar. In a previous version, you used to have some type checking to see if a parameter was a number, id, regex ,etc. Is this coming back?

Do we need both pages and routes as keywords? I think that could be confusing. Could we use the same keyword?

How do the file and class name conventions interact. Does one override the other? Or is there a config setting where the developer chooses one or the other?

I will be interested in how your implementation of JWX's app goes to see if there are fundamental differences or the proposals just differ in syntax and conventions.

davismj commented 5 years ago

How are query parameters passed to the component? What if a path parameter and query parameter share a name? Does one override or do you pass an array of both values?

Can you give an example to clarify what you mean?

In a previous version, you used to have some type checking to see if a parameter was a number, id, regex ,etc. Is this coming back?

Yes! This is only about how to register routes with the Router. This is orthogonal to other router functionality. In any case, the conventional parameters will not be typed.

Do we need both pages and routes as keywords? I think that could be confusing. Could we use the same keyword?

That's a really good point. I typically use 'pages' as my folder for pages in applications, which is why I used it here; it is illuminating. However, HomePage didn't feel as illuminating to the convention. What is your suggestion?

How do the file and class name conventions interact. Does one override the other?

Yes, class name > file name. I thought it was implicit in the order of the spec above, but now I can see that it isn't.

Or is there a config setting where the developer chooses one or the other?

TODO: Add router configuration to this spec.

EisenbergEffect commented 5 years ago

I like being able to get at the route table from DI. That's nice. The configuration of that looks pretty straight forward. Same for the static property and decorator approaches.

For the static property and decorator approaches, for JIT-mode, it seems those will need to be imported and registered somewhere in order for them to add their configuration to the router. I don't see the details of that process here, but there needs to be some way for that data to get into the route table in JIT mode. It can be done automatically in AOT-mode.

For the class name and path conventions, these are the pieces that need to be harmonized with the proposal from @jwx. The way the two of you describe these scenarios is quite different but I think the manifestation of them is very similar. They aren't really that different. Some of what is being described here is probably only possible through AOT since there's no loader abstraction in Aurelia vNext. We could make our vNext router depend on import as a standard and require a polyfill for that I supposed, and then you could do the path-based routing. But the class name routing won't work flawlessly in a minification scenario without a very specific module loader that we hack, which I don't want do do. It could work just fine in AOT mode though. So, for the class name approach, we may want to switch it to element name instead, and make it work with gobaly registered custom elements. In JIT-mode the name can be provided by the developer through the decorator or even through a module loader plugin provided by the end user. So, once registered in the container, the router can just resolve them by string name. I believe this is what @jwx is proposing. It's a slight pivot from what is here, but not so fundamentally different. This could be handled easily with AOT either way (which will generate the element decorator and auto-embed the element name based on the class name), but I'm looking for ways to make it work in both JIT and AOT mode without loader hacks. So, I think the element name could unify this.

For the path method looking for something in a "pages" folder, I think I'd want that configurable. It could be done in JIT, but again requires a module loader method, which isn't present anywhere in the framework at this point. It could be done easily in AOT, and in that way isn't much different from the basic name-based mapping.

I may be rambling a bit here. So, apologies. I think this is all very close to what we want, we just may need to tweak it a little based on some underlying constraints of JIT vs. AOT and align with the way the new decorators work. But, again, it's not very far off.

davismj commented 5 years ago

One issue with the approach is as follows.

Assume we have an application with a <au-viewport>, and therefore a Router, but no routes have been configured.

Assume further we have two available routes by path convention, 'pages/users' and 'pages/files'. The we have the following two valid behaviors that are not well defined.

Loads UserComponent first, fails to load Files.

  1. User navigates to 'user/123'.
  2. Aurelia attempts to load 'src/pages/user'.
  3. Aurelia finds 'src/pages/user' with a class UserComponent. 4 Aurelia finds an <au-viewport> in the template for UserComponent.
  4. Aurelia activates UserComponent with params { id: '123' }.
  5. Aurelia adds { path: 'user/{id?}', name: 'user', component: UserComponent } to the RouteTable.
  6. User navigates to 'user/file'
  7. Aurelia finds the path from step (5).
  8. Aurelia activates UserComponent with params `{ id: 'file' }

Loads UserComponent and Files first.

  1. User navigates to 'user/files/abc-123'.
  2. Aurelia attempts to load 'src/pages/user'.
  3. Aurelia finds 'src/pages/user' with a class UserComponent.
  4. Aurelia finds an <au-viewport> in the template for UserComponent.
  5. Aurelia attempts to load 'src/pages/files'.
  6. Aurelia finds 'src/pages/files' with a class Files.
  7. Aurelia activates UserComponent.
  8. Aurelia activates Files with params { id: 'abc-123' }.
  9. Aurelia adds { path: 'files/{id?}', name: 'files', parent: 'user', component: Files } to the RouteTable
  10. Aurelia adds { path: 'user/{id?}', name: 'user', component: UserComponent } to the RouteTable.
  11. User navigates to 'user/file'.
  12. Aurelia finds the IRouteConfig from 9-10.
  13. Aurelia activates UserComponent.
  14. Aurelia activates Files.
davismj commented 5 years ago

I don't see the details of that process here, but there needs to be some way for that data to get into the route table in JIT mode.

The same way that custom elements are registered. I'm not sure the details of that in vNext. In vCurrent I believe it would be .withResources. I'd peg it to whatever the custom element API is though.

We could make our vNext router depend on import as a standard and require a polyfill for that I supposed, and then you could do the path-based routing.

If that is the case, perhaps we put this down as a future plugin enhancement. The plugin depends on the loader / polyfill and configures the router with this behavior. At that point its just a matter of making sure there is a sufficient extension point in the router for this, which I think is very important even if we never made such a plugin.

But the class name routing won't work flawlessly in a minification scenario without a very specific module loader that we hack, which I don't want do.

How are we handling custom element conventions in vNext? Does MyCustomElement still register a custom element ''? I'd peg the route convention to the custom element convention, using Route as the keyword.

So, for the class name approach, we may want to switch it to element name instead, and make it work with gobaly registered custom elements.

The difference, and the need for a new convention, is that the convention would plug into the router and register a new route.

For the path method looking for something in a "pages" folder, I think I'd want that configurable.

Agreed. TODO.

I'm all for discarding or relegating path-based convention to a plugin. Just throwing ideas at the wall to see what sticks. As I mentioned in the previous comment, a big problem with that approach is that it is not well defined. Depending on the path you take you may get two different results.

EisenbergEffect commented 5 years ago

If the class name vs. element name is confusing anyone who reads this, I'll explain a little bit.

Let's say you write the following class:

export class Home {
  ...
}

Now, if you're using AOT, the compiler is going to come along and do something like this:

import { customElement } from '@aurelia/runtime';

@customElement({
  name: 'home', // derived by convention
  template: 'your html template from home.html is embedded here by convention'
})
export class Home {
  ...
}

So, the class name becomes the element name when AOT does it's magic. But, if you are using JIT mode with something like Require.js or System.js, you can use a nice loader plugin to do something like this:

import { customElement } from 'au!./home.html';

@customElement
export class Home {}

Now, here the module loader plugin au turns the home.html resource into a template definition and grabs the home name from the home.html file name. So, you get a very similar effect as AOT, but in JIT mode and with very little ceremony.

In both AOT and JIT cases, at runtime, you end up with a @customElement decorator associating the name value with the class. So, from there, you only need to register it somewhere. In vNext, there's no need for special ViewResources machinery to register this with. It's all done with the container. In fact, the @customElement decorator associates an auto-registration behavior with the class, so all you have to do is register the class with DI to turn it into a global resource. That looks something like this:

const container = getContainerFromSomewhereNotShown();
container.register(Home);

So, now you can have a Router that does something like this:

const path = "home";
const componentInstance = container.get(CustomElementResource.keyFrom(path));

And boom, you've got your string path name mapped to a component. I hope that makes sense.

EisenbergEffect commented 5 years ago

Note, the au plugin described above is something I've written and am using in my own app development and testing of vNext. It works exactly as described above :) It literally generates a decorator on the fly based on the framework's decorator with the name, template, dependencies, etc. already pre-configured, so all you have to do is tag it on the class. The plugin actually creates a couple of other things that are handy in other scenarios as well :)

davismj commented 5 years ago

Re the above and team chat, I've removed the path convention from the spec.

jwx commented 5 years ago

@EisenbergEffect Since the router is resolving the string name components against the DI container of the au-viewport all of your components that are loaded with your au plugin should (providing I've understood your plugin correctly) be routable right now with both

<a href="home">Home</a>

and

router.goto('home');

without you needing to do anything else. Have you tried it? If not, could you just do a quick test and let me know how it turns out?

EisenbergEffect commented 5 years ago

The app where I have my plugin isn't currently using the router but it does register all elements from a view into the container as dependencies, so in theory, it should work without any issues.

Sayan751 commented 1 year ago

I am closing this, as the requirements outlined in the original post are all supported by router-lite. The API may look different, but those are supported anyway. Here are the docs for that: https://docs.aurelia.io/router-lite/getting-started.

Feel free t open, if I have overseen a requirement, that is not yet supported.