Skip to content

Commit

Permalink
feat(router): auto-unwrap default exports when lazy loading (#47586)
Browse files Browse the repository at this point in the history
When using `loadChildren` or `loadComponent`, a common pattern is to pass
a function that returns a `Promise` from a dynamic import:

```typescript
{
  path: 'lazy',
  loadComponent: () => import('./lazy-file').then(m => m.LazyCmp),
}
```

The `.then` part of the expression selects the particular exported
component symbol from the dynamically imported ES module.

ES modules can have a "default export", created with the `export default`
modifier:

```typescript
@component({...})
export default class LazyCmp { ... }
```

This default export is made available to dynamic imports under the well-
known key of `'default'`, per the ES module spec:
https://tc39.es/ecma262/#table-export-forms-mapping-to-exportentry-records

This commit adds a feature to the router to automatically dereference such
default exports. With this logic, when `export default` is used, a `.then`
operation to select the particular exported symbol is no longer required:

```typescript
{
  path: 'lazy',
  loadComponent: () => import('./lazy-file'),
}
```

The above `loadComponent` operation will automatically use the `default`
export of the `lazy-file` ES module.

This functionality works for `loadChildren` as well.

PR Close #47586
  • Loading branch information
alxhub authored and AndrewKushnir committed Oct 4, 2022
1 parent a2a066d commit da58801
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 17 deletions.
19 changes: 19 additions & 0 deletions aio/content/guide/standalone-components.md
Expand Up @@ -148,6 +148,25 @@ export const ADMIN_ROUTES: Route[] = [
];
```

### Lazy loading and default exports

When using `loadChildren` and `loadComponent`, the router understands and automatically unwraps dynamic `import()` calls with `default` exports. You can take advantage of this to skip the `.then()` for such lazy loading operations.

```ts
// In the main application:
export const ROUTES: Route[] = [
{path: 'admin', loadChildren: () => import('./admin/routes')},
// ...
];

// In admin/routes.ts:
export default [
{path: 'home', component: AdminHomeComponent},
{path: 'users', component: AdminUsersComponent},
// ...
] as Route[];
```

### Providing services to a subset of routes

The lazy loading API for `NgModule`s (`loadChildren`) creates a new "module" injector when it loads the lazily loaded children of a route. This feature was often useful to provide services only to a subset of routes in the application. For example, if all routes under `/admin` were scoped using a `loadChildren` boundary, then admin-only services could be provided only to those routes. Doing this required using the `loadChildren` API, even if lazy loading of the routes in question was unnecessary.
Expand Down
9 changes: 7 additions & 2 deletions goldens/public-api/router/index.md
Expand Up @@ -212,6 +212,11 @@ export type Data = {
// @public
export type DebugTracingFeature = RouterFeature<RouterFeatureKind.DebugTracingFeature>;

// @public
export interface DefaultExport<T> {
default: T;
}

// @public
export class DefaultTitleStrategy extends TitleStrategy {
constructor(title: Title);
Expand Down Expand Up @@ -359,7 +364,7 @@ export interface IsActiveMatchOptions {
export type LoadChildren = LoadChildrenCallback | ɵDeprecatedLoadChildren;

// @public
export type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Routes | Observable<Type<any> | Routes> | Promise<NgModuleFactory<any> | Type<any> | Routes>;
export type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Routes | Observable<Type<any> | Routes | DefaultExport<Type<any>> | DefaultExport<Routes>> | Promise<NgModuleFactory<any> | Type<any> | Routes | DefaultExport<Type<any>> | DefaultExport<Routes>>;

// @public
export interface Navigation {
Expand Down Expand Up @@ -588,7 +593,7 @@ export interface Route {
component?: Type<any>;
data?: Data;
loadChildren?: LoadChildren;
loadComponent?: () => Type<unknown> | Observable<Type<unknown>> | Promise<Type<unknown>>;
loadComponent?: () => Type<unknown> | Observable<Type<unknown> | DefaultExport<Type<unknown>>> | Promise<Type<unknown> | DefaultExport<Type<unknown>>>;
matcher?: UrlMatcher;
outlet?: string;
path?: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Expand Up @@ -1481,6 +1481,9 @@
{
"name": "matrixParamsMatch"
},
{
"name": "maybeUnwrapDefaultExport"
},
{
"name": "maybeUnwrapEmpty"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Expand Up @@ -12,7 +12,7 @@ export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode as NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanActivateChildFn, CanActivateFn, CanDeactivate, CanDeactivateFn, CanLoad, CanLoadFn, CanMatch, CanMatchFn, Data, LoadChildren, LoadChildrenCallback, NavigationBehaviorOptions, QueryParamsHandling, Resolve, ResolveData, ResolveFn, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {CanActivate, CanActivateChild, CanActivateChildFn, CanActivateFn, CanDeactivate, CanDeactivateFn, CanLoad, CanLoadFn, CanMatch, CanMatchFn, Data, DefaultExport, LoadChildren, LoadChildrenCallback, NavigationBehaviorOptions, QueryParamsHandling, Resolve, ResolveData, ResolveFn, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
export {DebugTracingFeature, DisabledInitialNavigationFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withInMemoryScrolling, withPreloading, withRouterConfig} from './provide_router';
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
Expand Down
29 changes: 27 additions & 2 deletions packages/router/src/models.ts
Expand Up @@ -89,6 +89,22 @@ export type ResolveData = {
[key: string|symbol]: any|ResolveFn<unknown>
};

/**
* An ES Module object with a default export of the given type.
*
* @see `Route#loadComponent`
* @see `LoadChildrenCallback`
*
* @publicApi
*/
export interface DefaultExport<T> {
/**
* Default exports are bound under the name `"default"`, per the ES Module spec:
* https://tc39.es/ecma262/#table-export-forms-mapping-to-exportentry-records
*/
default: T;
}

/**
*
* A function that is called to resolve a collection of lazy-loaded routes.
Expand All @@ -113,11 +129,19 @@ export type ResolveData = {
* }];
* ```
*
* If the lazy-loaded routes are exported via a `default` export, the `.then` can be omitted:
* ```
* [{
* path: 'lazy',
* loadChildren: () => import('./lazy-route/lazy.routes'),
* }];
*
* @see [Route.loadChildren](api/router/Route#loadChildren)
* @publicApi
*/
export type LoadChildrenCallback = () => Type<any>|NgModuleFactory<any>|Routes|
Observable<Type<any>|Routes>|Promise<NgModuleFactory<any>|Type<any>|Routes>;
Observable<Type<any>|Routes|DefaultExport<Type<any>>|DefaultExport<Routes>>|
Promise<NgModuleFactory<any>|Type<any>|Routes|DefaultExport<Type<any>>|DefaultExport<Routes>>;

/**
*
Expand Down Expand Up @@ -439,7 +463,8 @@ export interface Route {
/**
* An object specifying a lazy-loaded component.
*/
loadComponent?: () => Type<unknown>| Observable<Type<unknown>>| Promise<Type<unknown>>;
loadComponent?: () => Type<unknown>| Observable<Type<unknown>|DefaultExport<Type<unknown>>>|
Promise<Type<unknown>|DefaultExport<Type<unknown>>>;
/**
* Filled for routes `loadComponent` once the component is loaded.
* @internal
Expand Down
35 changes: 26 additions & 9 deletions packages/router/src/router_config_loader.ts
Expand Up @@ -11,7 +11,7 @@ import {ConnectableObservable, from, Observable, of, Subject} from 'rxjs';
import {catchError, finalize, map, mergeMap, refCount, tap} from 'rxjs/operators';

import {deprecatedLoadChildrenString} from './deprecated_load_children';
import {LoadChildren, LoadChildrenCallback, LoadedRouterConfig, Route, Routes} from './models';
import {DefaultExport, LoadChildren, LoadChildrenCallback, LoadedRouterConfig, Route, Routes} from './models';
import {flatten, wrapIntoObservable} from './utils/collection';
import {assertStandalone, standardizeConfig, validateConfig} from './utils/config';

Expand Down Expand Up @@ -56,6 +56,7 @@ export class RouterConfigLoader {
}
const loadRunner = wrapIntoObservable(route.loadComponent!())
.pipe(
map(maybeUnwrapDefaultExport),
tap(component => {
if (this.onLoadEndListener) {
this.onLoadEndListener(route);
Expand Down Expand Up @@ -127,13 +128,29 @@ export class RouterConfigLoader {
if (deprecatedResult) {
return deprecatedResult;
}

return wrapIntoObservable((loadChildren as LoadChildrenCallback)()).pipe(mergeMap((t) => {
if (t instanceof NgModuleFactory || Array.isArray(t)) {
return of(t);
} else {
return from(this.compiler.compileModuleAsync(t));
}
}));
return wrapIntoObservable((loadChildren as LoadChildrenCallback)())
.pipe(
map(maybeUnwrapDefaultExport),
mergeMap((t) => {
if (t instanceof NgModuleFactory || Array.isArray(t)) {
return of(t);
} else {
return from(this.compiler.compileModuleAsync(t));
}
}),
);
}
}

function isWrappedDefaultExport<T>(value: T|DefaultExport<T>): value is DefaultExport<T> {
// We use `in` here with a string key `'default'`, because we expect `DefaultExport` objects to be
// dynamically imported ES modules with a spec-mandated `default` key. Thus we don't expect that
// `default` will be a renamed property.
return value && typeof value === 'object' && 'default' in value;
}

function maybeUnwrapDefaultExport<T>(input: T|DefaultExport<T>): T {
// As per `isWrappedDefaultExport`, the `default` key here is generated by the browser and not
// subject to property renaming, so we reference it with bracket access.
return isWrappedDefaultExport(input) ? input['default'] : input;
}
17 changes: 17 additions & 0 deletions packages/router/test/default_export_component.ts
@@ -0,0 +1,17 @@
/**
* @license
* Copyright Google LLC 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';

@Component({
standalone: true,
template: 'default exported',
selector: 'test-route',
})
export default class TestRoute {
}
23 changes: 23 additions & 0 deletions packages/router/test/default_export_routes.ts
@@ -0,0 +1,23 @@
/**
* @license
* Copyright Google LLC 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';
import {Routes} from '@angular/router';

@Component({
standalone: true,
template: 'default exported',
selector: 'test-route',
})
export class TestRoute {
}


export default [
{path: '', pathMatch: 'full', component: TestRoute},
] as Routes;
34 changes: 31 additions & 3 deletions packages/router/test/standalone.spec.ts
Expand Up @@ -9,10 +9,9 @@
import {Component, Injectable, NgModule} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Router} from '@angular/router';
import {NavigationEnd, Router, RouterModule} from '@angular/router';
import {RouterTestingModule} from '@angular/router/testing';

import {RouterModule} from '../src';
import {filter, first} from 'rxjs/operators';

@Component({template: '<div>simple standalone</div>', standalone: true})
export class SimpleStandaloneComponent {
Expand Down Expand Up @@ -340,6 +339,35 @@ describe('standalone in Router API', () => {
expect(() => advance(root)).toThrowError(/.*home.*component must be standalone/);
}));
});
describe('default export unwrapping', () => {
it('should work for loadComponent', async () => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{
path: 'home',
loadComponent: () => import('./default_export_component'),
}])],
});

const root = TestBed.createComponent(RootCmp);
await TestBed.inject(Router).navigateByUrl('/home');

expect(root.nativeElement.innerHTML).toContain('default exported');
});

it('should work for loadChildren', async () => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{
path: 'home',
loadChildren: () => import('./default_export_routes'),
}])],
});

const root = TestBed.createComponent(RootCmp);
await TestBed.inject(Router).navigateByUrl('/home');

expect(root.nativeElement.innerHTML).toContain('default exported');
});
});
});

function advance(fixture: ComponentFixture<unknown>) {
Expand Down

0 comments on commit da58801

Please sign in to comment.