Skip to content

fix(router): empty outlet component respecting the current outlet#30444

Closed
matheo wants to merge 3 commits intoangular:masterfrom
matheo:matheo-patch-1
Closed

fix(router): empty outlet component respecting the current outlet#30444
matheo wants to merge 3 commits intoangular:masterfrom
matheo:matheo-patch-1

Conversation

@matheo
Copy link
Copy Markdown

@matheo matheo commented May 14, 2019

PR Type

What kind of change does this PR introduce?

  • Bugfix

What is the current behavior?

Currently, the EmptyOutletComponent doesn't include a router-outlet for the current named route, and it doesn't display the component of the current route.

Issue Number: #10981

Refs: #10981 (comment)

What is the new behavior?

It will pass the current outlet name to the router-outlet

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

@jasonaden Do you know if moving the RouterOutlet.name from @Attribute to @Input has a lifecycle implication? any collateral issue will be seen in the tests? Thanks in advance!

@matheo matheo requested a review from a team May 14, 2019 04:08
@googlebot

This comment has been minimized.

@matheo
Copy link
Copy Markdown
Author

matheo commented May 14, 2019

I signed it!

@googlebot
Copy link
Copy Markdown

CLAs look good, thanks!

ℹ️ Googlers: Go here for more info.

@matheo
Copy link
Copy Markdown
Author

matheo commented May 14, 2019

Well @jasonaden
It seems that this has some lifecycle implications, and this cannot be handled with <router-outlet [attr.name]="activatedRoute.outlet">, so my current workaround is a custom NamedOutletComponent with this template:

<ng-container [ngSwitch]="route.outlet">
  <router-outlet *ngSwitchCase="'order'" name="order"></router-outlet>
  <router-outlet *ngSwitchCase="'customer'" name="customer"></router-outlet>
  <router-outlet *ngSwitchCase="'tickets'" name="tickets"></router-outlet>
</ng-container>

dirty but works for my lazy-loaded outlets:

{
  path: 'display,
  component: DisplayContainerComponent,
  children: [
    {
      path: ':id',
      component: NamedOutletComponent,
      loadChildren: './tickets/tickets.list.module#AdminTicketsListModule',
      outlet: 'order'
    },
    {
      path: ':id',
      component: NamedOutletComponent,
      loadChildren: './tickets/tickets.list.module#AdminTicketsListModule',
      outlet: 'customer'
    }
  ]
}

This case is not covered by the current EmptyOutletComponent nor the integration tests.

@ngbot ngbot bot added this to the needsTriage milestone May 15, 2019
Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @matheo, are you still interested in moving this forward? If so, could you please add some tests for this? It looks like there are two changes that need testing - the change which updates router_outlet to use @Input instead of @Attribute and the change in EmptyOutletComponent. Also, please submit an issue with a reproduction since the linked issue is closed and the specific comment you linked to doesn't have a reproduction either.

@atscott atscott added action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews router: aux routes router: directives RouterLink, RouterLinkActive, RouterOutlet etc. labels Aug 10, 2020
@matheo
Copy link
Copy Markdown
Author

matheo commented Aug 11, 2020

Hi @atscott, glad you're looking to this PR!
I've setup a reproduction case at https://stackblitz.com/edit/angular-pull-30444
with the kind of setup that highlighted this stuff, using outlets to implement some "entity widgets".

I see that you are working on #38379, can you help me to setup some tests please?
I need help with them because currently I haven't the time nor the experience with this kind of stuff :(
I have never built the angular packages locally to be able to check that the final result is the expected one.

@matheo matheo force-pushed the matheo-patch-1 branch 2 times, most recently from 8a7c869 to 6431d8c Compare August 11, 2020 05:12
Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @matheo, thanks for the example! I can definitely see what you're saying. As for the tests, take a look at the ones that were added to integration.spec.ts in the original commit for EmptyOutletComponent: 5731d07. It's pretty clear that those tests also didn't consider anything but a single empty path match in the child for the auxiliary route. If you can add another one that has a non-empty path which would need to have the correct name on the router-outlet like in your example, that should be good.

Running the tests would be relatively simple.

  1. install required node modules with yarn
  2. run the tests: yarn bazel test //packages/router/test

Additionally, what's the motivation behind the change from @Attribute to @Input on router-outlet? Not that I don't think it's a more common way of doing things and that it probably should have been @Inputfrom the start, but that we should limit the changes here to what's necessary and only fix one thing at a time.

Also FYI, I rebased your PR so you'll want to fetch the latest from your remote before making any changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Input() name!: string;
@Input() name?: string;

* to this `EmptyOutletComponent`.
*/
@Component({template: `<router-outlet></router-outlet>`})
@Component({template: `<router-outlet [name]="route.outlet"></router-outlet>`})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is not going to work in all cases, and causes the tests to fail. Consider the following config:

{
  path: 'parent', 
  outlet: 'nav', 
  children: [
    {path: '', component: SomeComponent}
 ]
}

This is the config that the tests are using, which currently works because it's able to match the primary outlet to the EmptyOutlet's RouterOutlet. Unfortunately, I think that's the only type of route that's going to work.

{
  path: 'parent', 
  outlet: 'nav', 
  children: [
    {path: '', component: SomeComponent},
    {path: 'somethingElse', component: SomeComponent},
    {path: 'somethingElse', outlet: 'nav', component: SomeComponent},
 ]
}

From what I can tell, neither the second nor third Route above will have any chance of activating. The second route is kind of just wrong--arguably, the first is as well - I don't really understand activating a primary route inside a secondary, but since it's an empty path it works even if it shouldn't. The third route can't ever activate because the fix you have here isn't present.

I think what this means is that you're going to have to also update standardizeConfig to account for this. Child Routes of auxiliary routes should not be the primary outlet.

export function standardizeConfig(r: Route, parent?: Route): Route {
  if (parent && parent.outlet !== PRIMARY_OUTLET && (r.outlet === PRIMARY_OUTLET || r.outlet === undefined)) {
    r.outlet = parent.outlet;
  }
  ...
}

This change is a bit scary -- it feels like it should work but I don't know if there are any weird scenarios I'm not considering.

@matheo
Copy link
Copy Markdown
Author

matheo commented Aug 12, 2020

@atscott thanks for the info, I've tried to workaround the RouterOutlet.name @Input with no joy
the idea was to let EmptyOutletComponent to pass the respective outlet name as I did with the ngSwitch workaround.

Looking at the implications of this, now I understand that ActivateRoutes.activateRoutes needs the outlets to be set in the constructor, because the RouterOutlet.ngOnInit runs after the router already reported A router outlet has not been instantiated during routes activation.

The router is a whole universe, maybe we could ask @vsavkin for hints
to understand if this case scenario is too crazy for the original concept.

@atscott
Copy link
Copy Markdown
Contributor

atscott commented Aug 12, 2020

@atscott thanks for the info, I've tried to workaround the RouterOutlet.name @Input with no joy
the idea was to let EmptyOutletComponent to pass the respective outlet name as I did with the ngSwitch workaround.

Looking at the implications of this, now I understand that ActivateRoutes.activateRoutes needs the outlets to be set in the constructor, because the RouterOutlet.ngOnInit runs after the router already reported A router outlet has not been instantiated during routes activation.

That would explain why @Attribute was used instead of @Input. Why do you need to change it to @Input though? Is there a reason this wouldn't work using @Attribute still?

The router is a whole universe, maybe we could ask @vsavkin for hints
to understand if this case scenario is too crazy for the original concept.

Victor isn't on the team any longer and neither is Jason so we have to learn the universe ourselves. The change you have for EmptyOutletComponent seems correct as I described above and it's reasonable to want routes other than just the empty path match there. It may not make sense to have primary routes under a named outlet which is what the component is doing right now.

Edit: That may just be how it works though. Child routes under a named outlet seem to be written as primary routes, without the outlet name of their parent. Maybe that makes sense too so you're not writing named outlets within named outlets and you don't have to remember the parent outlet name. The routes of the children could then be used inside of a named outlet or on their own. If that's the case then I suppose the named outlets aren't intended to be used the way your example is trying to use them.

@matheo
Copy link
Copy Markdown
Author

matheo commented Aug 12, 2020

@atscott thanks for this pair-programming async session hehe
I've added a lazy attribute to router-outlet so it doesn't try to initialize itself in the constructor, but in the ngOnInit, which is also supported by ActivateRoutes.activateRoutes :)

I've also included your considerations around standardizeConfig so we inherit the outlet along the tree of children or lazy-loaded modules (loadChildren) but your consideration is right about the original design. Primary routes are supported inside secondary outlets because all Angular Apps uses a plain <router-outlet> and the name is not mandatory, it defaults to primary as "wildcard" but not strictly speaking to a "primary" tree.

I will think about it again. Thanks for the feedback tho!

@atscott
Copy link
Copy Markdown
Contributor

atscott commented Aug 12, 2020

I've added a lazy attribute to router-outlet so it doesn't try to initialize itself in the constructor, but in the ngOnInit, which is also supported by ActivateRoutes.activateRoutes :)

It's still not clear why you need to change it to an @Input in the first place. Why is this necessary (because you @Attribute has to be a static value)? It would be good to a comment in the RouterOutlet about why @Attribute doesn't work and why lazy is needed with @Input.

I've also included your considerations around standardizeConfig so we inherit the outlet along the tree of children or lazy-loaded modules (loadChildren) but your consideration is right about the original design.

Let's refactor this to be standardizeConfig(route: Route, parent: Route|null). The function is only used internally in a few places and it would be better to be explicit about how it's being used rather than having optional parameters.

Primary routes are supported inside secondary outlets

Yes, but the question really is: why doesn't this work for you? Unless you need the outlet name to be correct (which you can still do by looking at the pathFromRoot in the ActivatedRoute and read the outlet names in each to determine it), your example could be rewritten to just use a single Route for the primary outlet: https://stackblitz.com/edit/angular-pull-30444-mjqzs3?file=src%2Fapp%2Fentity%2Fentity.module.ts. If the problem is that you actually need different configurations based on the outlet, then you should create different routes rather than trying to share one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than adding lazy as an attribute, can you change name to be string|null? If the outlet is using name as an input, what is the @Attribute value? Is it null?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the name attribute is fixed in the HTML of the current Angular Apps, if it's not present it defaults to primary and currently there's no way to wait for ngOnInit to activate the route. For backwards-compatibility I thought we leave the name attribute as it is.

But yeah, we could wait for ngOnInit to check the [name] and then initialize the primary outlet, but I'm afraid that we will pollute the existing Apps with the A router outlet has not been instantiated during routes activation warning in devMode, which is not ideal.

Copy link
Copy Markdown
Author

@matheo matheo Aug 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lazy attribute is clean from the backward-compatility perspective IMHO, and only advanced apps would use it to exploit the power of the Router.

The reason I didn't use any other workaround,
is because I do load/unload different components via outlets programatically using this in my container component:

this.router.navigate(
  [{ outlets: { chat: [uid], emails: [uid, 'emails' } ],
  {
    relativeTo: this.route,
    queryParamsHandling: 'preserve',
    skipLocationChange: true
  }
)

and the Router takes care of that complexity while I just style the outlets in my container.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't use any other workaround,
is because I do load/unload different components via outlets programatically using this in my container component:

this.router.navigate(
  [{ outlets: { chat: [uid], emails: [uid, 'emails' } ],
  {
    relativeTo: this.route,
    queryParamsHandling: 'preserve',
    skipLocationChange: true
  }
)

This doesn't seem explicitly incompatible with the way things work without your change though. Can you share your Routes and explain why you can't modify them to work as they are? Loading separate components seems possible like that; you would just need separate modules rather than sharing one like you have in your example.

None of this discussion is because I think your change isn't correct. I just want to make sure we're totally understanding what's going on and only making the change if we're convinced the current implementation is wrong, there isn't a reasonable workaround, that the change is not breaking, and that it doesn't cause additional confusion. I haven't seen any issue reports for this and nobody else has commented/reacted to this PR so it's not clear that anyone else is expecting things to work this way.

Copy link
Copy Markdown
Author

@matheo matheo Aug 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, and thanks for this @atscott
I think that with this exercise I learned more about the Router original concept.

The outlets are not supposed to open a whole new "branch" in the routing-tree,
but a little "slot" in a specific level of the hierarchy, am I right?

I will try to experiment with this a bit more in a Nx monorepo when I have some time
and come back to you with a more complete application to evaluate. Thanks again!

Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests in integration.spec.ts should be expanded to test additional configurations rather than the single empty path in the children. Add some with a path that's not empty and verify things work as they did before. Try adding some with outlets other than the primary as well.

matheo added 2 commits August 12, 2020 14:15
RouterOutlet now includes the `name` Input and the `lazy` attribute
to activate a secondary route from its ngOnInit,
so EmptyOutletComponent can do its job properly.
Non primary routes will inherit their outlet name to their primary direct children
name!: string;

constructor(public route: ActivatedRoute) {
// lazy loaded children are handled as primary routes
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation behind the distinction here? Don't lazy loaded children act the same way as regular children after they're loaded?

Copy link
Copy Markdown
Author

@matheo matheo Aug 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the previous attempt to modify standardizeConfig I realized that lazy loaded modules are supposed to use the primary outlet, no needing to know the parent outlet to load their children, so basically I will figure out the routing-hierarchy and what happens with the outlets in the same hierarchy level.

So I will test my use-case for different outlets in a lazy-loaded module.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't use any other workaround,
is because I do load/unload different components via outlets programatically using this in my container component:

this.router.navigate(
  [{ outlets: { chat: [uid], emails: [uid, 'emails' } ],
  {
    relativeTo: this.route,
    queryParamsHandling: 'preserve',
    skipLocationChange: true
  }
)

This doesn't seem explicitly incompatible with the way things work without your change though. Can you share your Routes and explain why you can't modify them to work as they are? Loading separate components seems possible like that; you would just need separate modules rather than sharing one like you have in your example.

None of this discussion is because I think your change isn't correct. I just want to make sure we're totally understanding what's going on and only making the change if we're convinced the current implementation is wrong, there isn't a reasonable workaround, that the change is not breaking, and that it doesn't cause additional confusion. I haven't seen any issue reports for this and nobody else has commented/reacted to this PR so it's not clear that anyone else is expecting things to work this way.

@matheo matheo closed this Aug 12, 2020
@angular-automatic-lock-bot
Copy link
Copy Markdown

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Sep 12, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews area: router cla: yes router: aux routes router: directives RouterLink, RouterLinkActive, RouterOutlet etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants