Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(router): auto-unwrap default exports when lazy loading #47586

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
11 changes: 8 additions & 3 deletions goldens/public-api/router/index.md
Expand Up @@ -213,6 +213,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 @@ -357,10 +362,10 @@ export interface IsActiveMatchOptions {
}

// @public
export type LoadChildren = LoadChildrenCallback;
export type LoadChildren = LoadChildrenCallback | ɵDeprecatedLoadChildren;
Copy link
Contributor

Choose a reason for hiding this comment

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

ɵDeprecatedLoadChildren now appears in the public API even though it's not exported and is unused externally. What about moving the LoadChildren type to its own file that we patch in g3 to add ɵDeprecatedLoadChildren?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep - that's fine. The goal is to not need to touch the definition of LoadChildren at all in the patch file - that's what leads to brittle patch files.


// @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 @@ -589,7 +594,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
26 changes: 26 additions & 0 deletions packages/router/src/deprecated_load_children.ts
@@ -0,0 +1,26 @@
/**
* @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 {Injector, NgModuleFactory} from '@angular/core';
import {Observable} from 'rxjs';

// This file exists to support the legacy `loadChildren: string` behavior being patched back into
// Angular.

/**
* Deprecated `loadChildren` value types.
*
* @publicApi
* @deprecated represents the deprecated type side of `LoadChildren`.
*/
export type DeprecatedLoadChildren = never;

export function deprecatedLoadChildrenString(
injector: Injector, loadChildren: unknown): Observable<NgModuleFactory<any>>|null {
return null;
}
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
32 changes: 29 additions & 3 deletions packages/router/src/models.ts
Expand Up @@ -9,6 +9,7 @@
import {EnvironmentInjector, ImportedNgModuleProviders, InjectionToken, NgModuleFactory, Provider, ProviderToken, Type} from '@angular/core';
import {Observable} from 'rxjs';

import {DeprecatedLoadChildren} from './deprecated_load_children';
import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';

Expand Down Expand Up @@ -88,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 @@ -112,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 All @@ -125,7 +150,7 @@ export type LoadChildrenCallback = () => Type<any>|NgModuleFactory<any>|Routes|
* @see `LoadChildrenCallback`
* @publicApi
*/
export type LoadChildren = LoadChildrenCallback;
export type LoadChildren = LoadChildrenCallback|DeprecatedLoadChildren;

/**
*
Expand Down Expand Up @@ -438,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
1 change: 1 addition & 0 deletions packages/router/src/private_export.ts
Expand Up @@ -8,6 +8,7 @@


export {ɵEmptyOutletComponent} from './components/empty_outlet';
export {DeprecatedLoadChildren as ɵDeprecatedLoadChildren} from './deprecated_load_children';
export {withPreloading as ɵwithPreloading} from './provide_router';
export {assignExtraOptionsToRouter as ɵassignExtraOptionsToRouter, RestoredState as ɵRestoredState} from './router';
export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module';
Expand Down
39 changes: 31 additions & 8 deletions packages/router/src/router_config_loader.ts
Expand Up @@ -10,7 +10,8 @@ import {Compiler, EnvironmentInjector, Injectable, InjectFlags, InjectionToken,
import {ConnectableObservable, from, Observable, of, Subject} from 'rxjs';
import {catchError, finalize, map, mergeMap, refCount, tap} from 'rxjs/operators';

import {LoadChildren, LoadedRouterConfig, Route, Routes} from './models';
import {deprecatedLoadChildrenString} from './deprecated_load_children';
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 @@ -55,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 @@ -122,12 +124,33 @@ export class RouterConfigLoader {

private loadModuleFactoryOrRoutes(loadChildren: LoadChildren):
Observable<NgModuleFactory<any>|Routes> {
return wrapIntoObservable(loadChildren()).pipe(mergeMap((t) => {
if (t instanceof NgModuleFactory || Array.isArray(t)) {
return of(t);
} else {
return from(this.compiler.compileModuleAsync(t));
}
}));
const deprecatedResult = deprecatedLoadChildrenString(this.injector, loadChildren);
if (deprecatedResult) {
return deprecatedResult;
}
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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering what the DX would look like in case of missing default export? Can we add a test to check that?

I was thinking of a couple scenarios:

  • no exports at all (we can have an error message explaining this)
  • no default exports, but other symbols are exported (we can also throw a message explaining that)

WDYT?

(we can also do that in a followup PR if needed, it's not a blocker)

Copy link
Contributor

Choose a reason for hiding this comment

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

TypeScript will fail in both cases:

If there are no exports, the TypeScript compiler will error with File 'the/imported/thing' is not a module.

If there is no default export, it would fail the DefaultExport<T> type.

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