diff --git a/packages/router/src/components/empty_outlet.ts b/packages/router/src/components/empty_outlet.ts new file mode 100644 index 0000000000..d5e99f5e68 --- /dev/null +++ b/packages/router/src/components/empty_outlet.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; + +/** + * This component is used internally within the router to be a placeholder when an empty + * router-outlet is needed. For example, with a config such as: + * + * `{path: 'parent', outlet: 'nav', children: [...]}` + * + * In order to render, there needs to be a component on this config, which will default + * to this `EmptyOutletComponent`. + */ +@Component({template: ``}) +export class EmptyOutletComponent { +} \ No newline at end of file diff --git a/packages/router/src/config.ts b/packages/router/src/config.ts index b1fe4b1e86..26512fbb28 100644 --- a/packages/router/src/config.ts +++ b/packages/router/src/config.ts @@ -8,6 +8,7 @@ import {NgModuleFactory, NgModuleRef, Type} from '@angular/core'; import {Observable} from 'rxjs'; +import {EmptyOutletComponent} from './components/empty_outlet'; import {PRIMARY_OUTLET} from './shared'; import {UrlSegment, UrlSegmentGroup} from './url_tree'; @@ -412,9 +413,10 @@ function validateNode(route: Route, fullPath: string): void { if (Array.isArray(route)) { throw new Error(`Invalid configuration of route '${fullPath}': Array cannot be specified`); } - if (!route.component && (route.outlet && route.outlet !== PRIMARY_OUTLET)) { + if (!route.component && !route.children && !route.loadChildren && + (route.outlet && route.outlet !== PRIMARY_OUTLET)) { throw new Error( - `Invalid configuration of route '${fullPath}': a componentless route cannot have a named outlet set`); + `Invalid configuration of route '${fullPath}': a componentless route without children or loadChildren cannot have a named outlet set`); } if (route.redirectTo && route.children) { throw new Error( @@ -477,8 +479,14 @@ function getFullPath(parentPath: string, currentRoute: Route): string { } } - -export function copyConfig(r: Route): Route { - const children = r.children && r.children.map(copyConfig); - return children ? {...r, children} : {...r}; +/** + * Makes a copy of the config and adds any default required properties. + */ +export function standardizeConfig(r: Route): Route { + const children = r.children && r.children.map(standardizeConfig); + const c = children ? {...r, children} : {...r}; + if (!c.component && (children || c.loadChildren) && (c.outlet && c.outlet !== PRIMARY_OUTLET)) { + c.component = EmptyOutletComponent; + } + return c; } diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 1075f6e4ce..aca5be0260 100644 --- a/packages/router/src/private_export.ts +++ b/packages/router/src/private_export.ts @@ -7,5 +7,6 @@ */ +export {EmptyOutletComponent as ɵEmptyOutletComponent} from './components/empty_outlet'; export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module'; export {flatten as ɵflatten} from './utils/collection'; diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index b4f28a84c0..f68be1ba97 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -12,7 +12,7 @@ import {BehaviorSubject, Observable, Subject, Subscription, of } from 'rxjs'; import {concatMap, map, mergeMap} from 'rxjs/operators'; import {applyRedirects} from './apply_redirects'; -import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, copyConfig, validateConfig} from './config'; +import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; @@ -357,7 +357,7 @@ export class Router { */ resetConfig(config: Routes): void { validateConfig(config); - this.config = config.map(copyConfig); + this.config = config.map(standardizeConfig); this.navigated = false; this.lastSuccessfulId = -1; } diff --git a/packages/router/src/router_config_loader.ts b/packages/router/src/router_config_loader.ts index 861df08248..292dc669ac 100644 --- a/packages/router/src/router_config_loader.ts +++ b/packages/router/src/router_config_loader.ts @@ -10,7 +10,7 @@ import {Compiler, InjectionToken, Injector, NgModuleFactory, NgModuleFactoryLoad // TODO(i): switch to fromPromise once it's expored in rxjs import {Observable, from, of } from 'rxjs'; import {map, mergeMap} from 'rxjs/operators'; -import {LoadChildren, LoadedRouterConfig, Route, copyConfig} from './config'; +import {LoadChildren, LoadedRouterConfig, Route, standardizeConfig} from './config'; import {flatten, wrapIntoObservable} from './utils/collection'; /** @@ -39,7 +39,8 @@ export class RouterConfigLoader { const module = factory.create(parentInjector); - return new LoadedRouterConfig(flatten(module.injector.get(ROUTES)).map(copyConfig), module); + return new LoadedRouterConfig( + flatten(module.injector.get(ROUTES)).map(standardizeConfig), module); })); } diff --git a/packages/router/src/router_module.ts b/packages/router/src/router_module.ts index 8a0c1c8bd6..b4a7eca8bd 100644 --- a/packages/router/src/router_module.ts +++ b/packages/router/src/router_module.ts @@ -11,6 +11,7 @@ import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, A import {ɵgetDOM as getDOM} from '@angular/platform-browser'; import {Subject, of } from 'rxjs'; +import {EmptyOutletComponent} from './components/empty_outlet'; import {Route, Routes} from './config'; import {RouterLink, RouterLinkWithHref} from './directives/router_link'; import {RouterLinkActive} from './directives/router_link_active'; @@ -36,7 +37,8 @@ import {flatten} from './utils/collection'; * * */ -const ROUTER_DIRECTIVES = [RouterOutlet, RouterLink, RouterLinkWithHref, RouterLinkActive]; +const ROUTER_DIRECTIVES = + [RouterOutlet, RouterLink, RouterLinkWithHref, RouterLinkActive, EmptyOutletComponent]; /** * @description @@ -128,7 +130,11 @@ export function routerNgProbeToken() { * * */ -@NgModule({declarations: ROUTER_DIRECTIVES, exports: ROUTER_DIRECTIVES}) +@NgModule({ + declarations: ROUTER_DIRECTIVES, + exports: ROUTER_DIRECTIVES, + entryComponents: [EmptyOutletComponent] +}) export class RouterModule { // Note: We are injecting the Router so it gets created eagerly... constructor(@Optional() @Inject(ROUTER_FORROOT_GUARD) guard: any, @Optional() router: Router) {} diff --git a/packages/router/test/config.spec.ts b/packages/router/test/config.spec.ts index 9ef3037c69..1f419f2b0e 100644 --- a/packages/router/test/config.spec.ts +++ b/packages/router/test/config.spec.ts @@ -123,21 +123,27 @@ describe('config', () => { }).toThrowError(/Invalid configuration of route '{path: "", redirectTo: "b"}'/); }); - it('should throw when pathPatch is invalid', () => { + it('should throw when pathMatch is invalid', () => { expect(() => { validateConfig([{path: 'a', pathMatch: 'invalid', component: ComponentB}]); }) .toThrowError( /Invalid configuration of route 'a': pathMatch can only be set to 'prefix' or 'full'/); }); - it('should throw when pathPatch is invalid', () => { - expect(() => { validateConfig([{path: 'a', outlet: 'aux', children: []}]); }) + it('should throw when path/outlet combination is invalid', () => { + expect(() => { validateConfig([{path: 'a', outlet: 'aux'}]); }) .toThrowError( - /Invalid configuration of route 'a': a componentless route cannot have a named outlet set/); - + /Invalid configuration of route 'a': a componentless route without children or loadChildren cannot have a named outlet set/); expect(() => validateConfig([{path: 'a', outlet: '', children: []}])).not.toThrow(); expect(() => validateConfig([{path: 'a', outlet: PRIMARY_OUTLET, children: []}])) .not.toThrow(); }); + + it('should not throw when path/outlet combination is valid', () => { + expect(() => { validateConfig([{path: 'a', outlet: 'aux', children: []}]); }).not.toThrow(); + expect(() => { + validateConfig([{path: 'a', outlet: 'aux', loadChildren: 'child'}]); + }).not.toThrow(); + }); }); }); diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index bdbfdb3f4d..80d4695ae1 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -3366,6 +3366,72 @@ describe('Integration', () => { expect(location.path()).toEqual('/lazy2/loaded'); }))); + it('should allow lazy loaded module in named outlet', + fakeAsync(inject( + [Router, NgModuleFactoryLoader], (router: Router, loader: SpyNgModuleFactoryLoader) => { + + @Component({selector: 'lazy', template: 'lazy-loaded'}) + class LazyComponent { + } + + @NgModule({ + declarations: [LazyComponent], + imports: [RouterModule.forChild([{path: '', component: LazyComponent}])] + }) + class LazyLoadedModule { + } + + loader.stubbedModules = {lazyModule: LazyLoadedModule}; + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'lazy', loadChildren: 'lazyModule', outlet: 'right'}, + ] + }]); + + + router.navigateByUrl('/team/22/user/john'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); + + router.navigateByUrl('/team/22/(user/john//right:lazy)'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: lazy-loaded ]'); + }))); + + it('should allow componentless named outlet to render children', + fakeAsync(inject( + [Router, NgModuleFactoryLoader], (router: Router, loader: SpyNgModuleFactoryLoader) => { + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'user/:name', component: UserCmp}, + {path: 'simple', outlet: 'right', children: [{path: '', component: SimpleCmp}]}, + ] + }]); + + + router.navigateByUrl('/team/22/user/john'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: ]'); + + router.navigateByUrl('/team/22/(user/john//right:simple)'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('team 22 [ user john, right: simple ]'); + }))); describe('should use the injector of the lazily-loaded configuration', () => { class LazyLoadedServiceDefinedInModule {} @@ -4102,6 +4168,10 @@ function createRoot(router: Router, type: any): ComponentFixture { return f; } +@Component({selector: 'lazy', template: 'lazy-loaded'}) +class LazyComponent { +} + @NgModule({ imports: [RouterTestingModule, CommonModule],