diff --git a/src/directives/uiView.ts b/src/directives/uiView.ts index dff42e14b..e181d62a6 100755 --- a/src/directives/uiView.ts +++ b/src/directives/uiView.ts @@ -1,13 +1,14 @@ import { Component, - ComponentFactory, - ComponentFactoryResolver, + ComponentMirror, ComponentRef, Inject, Injector, Input, OnDestroy, OnInit, + reflectComponentType, + Type, ViewChild, ViewContainerRef, } from '@angular/core'; @@ -58,8 +59,8 @@ interface InputMapping { * * @internal */ -const ng2ComponentInputs = (factory: ComponentFactory): InputMapping[] => { - return factory.inputs.map((input) => ({ prop: input.propName, token: input.templateName })); +function ng2ComponentInputs(mirror: ComponentMirror): InputMapping[] { + return mirror.inputs.map((input) => ({ prop: input.templateName, token: input.templateName })); }; /** @@ -293,12 +294,9 @@ export class UIView implements OnInit, OnDestroy { const componentClass = config.viewDecl.component; // Create the component - const compFactoryResolver = componentInjector.get(ComponentFactoryResolver); - const compFactory = compFactoryResolver.resolveComponentFactory(componentClass); - this._componentRef = this._componentTarget.createComponent(compFactory, undefined, componentInjector); - + this._componentRef = this._componentTarget.createComponent(componentClass, { injector: componentInjector }); // Wire resolves to @Input()s - this._applyInputBindings(compFactory, this._componentRef.instance, context, componentClass); + this._applyInputBindings(componentClass, this._componentRef, context); } /** @@ -327,7 +325,7 @@ export class UIView implements OnInit, OnDestroy { const moduleInjector = context.getResolvable(NATIVE_INJECTOR_TOKEN).data; const mergedParentInjector = new MergeInjector(moduleInjector, parentComponentInjector); - return Injector.create(newProviders, mergedParentInjector); + return Injector.create({ providers: newProviders, parent: mergedParentInjector }); } /** @@ -336,25 +334,19 @@ export class UIView implements OnInit, OnDestroy { * Finds component inputs which match resolves (by name) and sets the input value * to the resolve data. */ - private _applyInputBindings(factory: ComponentFactory, component: any, context: ResolveContext, componentClass) { + private _applyInputBindings(component: Type, componentRef: ComponentRef, context: ResolveContext): void { const bindings = this._uiViewData.config.viewDecl['bindings'] || {}; const explicitBoundProps = Object.keys(bindings); - - // Returns the actual component property for a renamed an input renamed using `@Input('foo') _foo`. - // return the `_foo` property - const renamedInputProp = (prop: string) => { - const input = factory.inputs.find((i) => i.templateName === prop); - return (input && input.propName) || prop; - }; + const mirror = reflectComponentType(component); // Supply resolve data to component as specified in the state's `bindings: {}` const explicitInputTuples = explicitBoundProps.reduce( - (acc, key) => acc.concat([{ prop: renamedInputProp(key), token: bindings[key] }]), + (acc, key) => acc.concat([{ prop: key, token: bindings[key] }]), [] ); // Supply resolve data to matching @Input('prop') or inputs: ['prop'] - const implicitInputTuples = ng2ComponentInputs(factory).filter((tuple) => !inArray(explicitBoundProps, tuple.prop)); + const implicitInputTuples = ng2ComponentInputs(mirror).filter((tuple) => !inArray(explicitBoundProps, tuple.prop)); const addResolvable = (tuple: InputMapping) => ({ prop: tuple.prop, @@ -368,7 +360,7 @@ export class UIView implements OnInit, OnDestroy { .map(addResolvable) .filter((tuple) => tuple.resolvable && tuple.resolvable.resolved) .forEach((tuple) => { - component[tuple.prop] = injector.get(tuple.resolvable.token); + componentRef.setInput(tuple.prop, injector.get(tuple.resolvable.token)); }); } } diff --git a/src/interface.ts b/src/interface.ts index 76e341dae..c9a0aee6e 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,6 +1,6 @@ import { StateDeclaration, _ViewDeclaration, Transition, HookResult } from '@uirouter/core'; import { Component, Type } from '@angular/core'; -import { ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; +import { ComponentTypeCallback, ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; /** * The StateDeclaration object is used to define a state or nested state. @@ -25,7 +25,7 @@ import { ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; * } * ``` */ -export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaration { +export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaration { /** * An optional object used to define multiple named views. * @@ -152,10 +152,28 @@ export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaratio * } * ``` */ - loadChildren?: ModuleTypeCallback; + loadChildren?: ModuleTypeCallback; + + /** + * A function used to lazy load a `Component`. + * + * When the state is activate the `loadComponent` property should lazy load a standalone `Component` + * and use it to render the view of the state + * + * ### Example: + * ```ts + * var homeState = { + * name: 'home', + * url: '/home', + * loadComponent: () => import('./home/home.component') + * .then(result => result.HomeComponent) + * } + * ``` + */ + loadComponent?: ComponentTypeCallback; } -export interface Ng2ViewDeclaration extends _ViewDeclaration { +export interface Ng2ViewDeclaration extends _ViewDeclaration { /** * The `Component` class to use for this view. * @@ -238,7 +256,7 @@ export interface Ng2ViewDeclaration extends _ViewDeclaration { * } * ``` */ - component?: Type; + component?: Type; /** * An object which maps `resolve` keys to [[component]] `bindings`. diff --git a/src/lazyLoad/lazyLoadNgModule.ts b/src/lazyLoad/lazyLoadNgModule.ts index e4d362ac1..d339b67ac 100644 --- a/src/lazyLoad/lazyLoadNgModule.ts +++ b/src/lazyLoad/lazyLoadNgModule.ts @@ -1,11 +1,10 @@ -import { NgModuleRef, Injector, NgModuleFactory, Type, Compiler } from '@angular/core'; +import { NgModuleRef, Injector, Type, createNgModule, InjectionToken, isStandalone } from '@angular/core'; import { Transition, LazyLoadResult, UIRouter, Resolvable, NATIVE_INJECTOR_TOKEN, - isString, unnestR, inArray, StateObject, @@ -15,6 +14,7 @@ import { import { UIROUTER_MODULE_TOKEN, UIROUTER_ROOT_MODULE } from '../injectionTokens'; import { RootModule, StatesModule } from '../uiRouterNgModule'; import { applyModuleConfig } from '../uiRouterConfig'; +import { Ng2StateDeclaration } from '../interface'; /** * A function that returns an NgModule, or a promise for an NgModule @@ -26,7 +26,7 @@ import { applyModuleConfig } from '../uiRouterConfig'; * } * ``` */ -export type ModuleTypeCallback = () => Type | Promise>; +export type ModuleTypeCallback = () => Type | Promise>; /** * Returns a function which lazy loads a nested module @@ -36,9 +36,7 @@ export type ModuleTypeCallback = () => Type | Promise>; * It could also be used manually as a [[StateDeclaration.lazyLoad]] property to lazy load an `NgModule` and its state(s). * * #### Example: - * Using `import()` and named export of `HomeModule` - * ```js - * declare var System; + * ```ts * var futureState = { * name: 'home.**', * url: '/home', @@ -46,19 +44,8 @@ export type ModuleTypeCallback = () => Type | Promise>; * } * ``` * - * #### Example: - * Using a path (string) to the module - * ```js - * var futureState = { - * name: 'home.**', - * url: '/home', - * lazyLoad: loadNgModule('./home/home.module#HomeModule') - * } - * ``` - * * - * @param moduleToLoad a path (string) to the NgModule to load. - * Or a function which loads the NgModule code which should + * @param moduleToLoad function which loads the NgModule code which should * return a reference to the `NgModule` class being loaded (or a `Promise` for it). * * @returns A function which takes a transition, which: @@ -67,17 +54,15 @@ export type ModuleTypeCallback = () => Type | Promise>; * - Finds the "replacement state" for the target state, and adds the new NgModule Injector to it (as a resolve) * - Returns the new states array */ -export function loadNgModule( - moduleToLoad: ModuleTypeCallback +export function loadNgModule( + moduleToLoad: ModuleTypeCallback ): (transition: Transition, stateObject: StateDeclaration) => Promise { return (transition: Transition, stateObject: StateDeclaration) => { - const ng2Injector = transition.injector().get(NATIVE_INJECTOR_TOKEN); - - const createModule = (factory: NgModuleFactory) => factory.create(ng2Injector); - const applyModule = (moduleRef: NgModuleRef) => applyNgModule(transition, moduleRef, ng2Injector, stateObject); + const ng2Injector = transition.injector().get(NATIVE_INJECTOR_TOKEN); - return loadModuleFactory(moduleToLoad, ng2Injector).then(createModule).then(applyModule); + return loadModuleFactory(moduleToLoad, ng2Injector) + .then(moduleRef => applyNgModule(moduleRef, ng2Injector, stateObject)); }; } @@ -90,22 +75,18 @@ export function loadNgModule( * * @internal */ -export function loadModuleFactory( - moduleToLoad: ModuleTypeCallback, +export function loadModuleFactory( + moduleToLoad: ModuleTypeCallback, ng2Injector: Injector -): Promise> { - const compiler: Compiler = ng2Injector.get(Compiler); - - const unwrapEsModuleDefault = (x) => (x && x.__esModule && x['default'] ? x['default'] : x); +): Promise> { return Promise.resolve(moduleToLoad()) - .then(unwrapEsModuleDefault) - .then((t: NgModuleFactory | Type) => { - if (t instanceof NgModuleFactory) { - return t; - } - return compiler.compileModuleAsync(t); - }); + .then(_unwrapEsModuleDefault) + .then((t: Type) => createNgModule(t, ng2Injector)); +} + +function _unwrapEsModuleDefault(x) { + return x && x.__esModule && x['default'] ? x['default'] : x; } /** @@ -122,9 +103,8 @@ export function loadModuleFactory( * * @internal */ -export function applyNgModule( - transition: Transition, - ng2Module: NgModuleRef, +export function applyNgModule( + ng2Module: NgModuleRef, parentInjector: Injector, lazyLoadState: StateDeclaration ): LazyLoadResult { @@ -192,8 +172,78 @@ export function applyNgModule( * * @internal */ -export function multiProviderParentChildDelta(parent: Injector, child: Injector, token: any) { - const childVals: RootModule[] = child.get(token, []); - const parentVals: RootModule[] = parent.get(token, []); +export function multiProviderParentChildDelta(parent: Injector, child: Injector, token: InjectionToken): RootModule[] { + const childVals: RootModule[] = child.get(token, []); + const parentVals: RootModule[] = parent.get(token, []); return childVals.filter((val) => parentVals.indexOf(val) === -1); } + +/** + * A function that returns a Component, or a promise for a Component + * + * #### Example: + * ```ts + * export function loadFooComponent() { + * return import('../foo/foo.component').then(result => result.FooComponent); + * } + * ``` + */ +export type ComponentTypeCallback = ModuleTypeCallback; + +/** + * Returns a function which lazy loads a standalone component for the target state + * + * #### Example: + * ```ts + * var futureComponentState = { + * name: 'home', + * url: '/home', + * lazyLoad: loadComponent(() => import('./home.component').then(result => result.HomeComponent)) + * } + * ``` + * + * @param callback function which loads the Component code which should + * return a reference to the `Component` class being loaded (or a `Promise` for it). + * + * @returns A function which takes a transition, stateObject, and: + * - Loads a standalone component + * - replaces the component configuration of the stateObject. + * - Returns the new states array + */ +export function loadComponent( + callback: ComponentTypeCallback +): (transition: Transition, stateObject: Ng2StateDeclaration) => Promise { + return (transition: Transition, stateObject: Ng2StateDeclaration) => { + + return Promise.resolve(callback()) + .then(_unwrapEsModuleDefault) + .then((component: Type) => applyComponent(component, transition, stateObject)) + } +} + +/** + * Apply the lazy-loaded component to the stateObject. + * + * @internal + * @param component reference to the component class + * @param transition Transition object reference + * @param stateObject target state configuration object + * + * @returns the new states array + */ +export function applyComponent( + component: Type, + transition: Transition, + stateObject: Ng2StateDeclaration +): LazyLoadResult { + + if (!isStandalone(component)) throw new Error("Is not a standalone component."); + + const registry = transition.router.stateRegistry; + const current = stateObject.component; + stateObject.component = component || current; + const removed = registry.deregister(stateObject).map(child => child.self); + const children = removed.filter(i => i.name != stateObject.name); + + return { states: [stateObject, ...children] } +} diff --git a/src/statebuilders/lazyLoad.ts b/src/statebuilders/lazyLoad.ts index 92e02fd06..2798282e7 100644 --- a/src/statebuilders/lazyLoad.ts +++ b/src/statebuilders/lazyLoad.ts @@ -1,6 +1,6 @@ import { LazyLoadResult, Transition, StateDeclaration } from '@uirouter/core'; // has or is using import { BuilderFunction, StateObject } from '@uirouter/core'; -import { loadNgModule } from '../lazyLoad/lazyLoadNgModule'; +import { loadComponent, loadNgModule } from '../lazyLoad/lazyLoadNgModule'; /** * This is a [[StateBuilder.builder]] function for ngModule lazy loading in Angular. @@ -46,6 +46,7 @@ import { loadNgModule } from '../lazyLoad/lazyLoadNgModule'; * */ export function ng2LazyLoadBuilder(state: StateObject, parent: BuilderFunction) { + const loadComponentFn = state['loadComponent']; const loadNgModuleFn = state['loadChildren']; - return loadNgModuleFn ? loadNgModule(loadNgModuleFn) : state.lazyLoad; + return loadComponentFn ? loadComponent(loadComponentFn) : loadNgModuleFn ? loadNgModule(loadNgModuleFn) : state.lazyLoad; } diff --git a/test-angular-versions/v19-standalone/cypress/e2e/sample_app.cy.js b/test-angular-versions/v19-standalone/cypress/e2e/sample_app.cy.js index f01159c7f..584659b5d 100644 --- a/test-angular-versions/v19-standalone/cypress/e2e/sample_app.cy.js +++ b/test-angular-versions/v19-standalone/cypress/e2e/sample_app.cy.js @@ -56,6 +56,7 @@ describe('Angular app', () => { cy.get('a').contains('home').should('not.have.class', 'active'); cy.get('a').contains('lazy.child').should('have.class', 'active'); cy.get('#default').contains('lazy.child works'); + cy.get('#lazy-child-provided').contains('provided value'); }); it('targets named views', () => { diff --git a/test-angular-versions/v19-standalone/src/app/lazy/lazy.module.ts b/test-angular-versions/v19-standalone/src/app/lazy/lazy.module.ts index 2ca79698a..74836c415 100644 --- a/test-angular-versions/v19-standalone/src/app/lazy/lazy.module.ts +++ b/test-angular-versions/v19-standalone/src/app/lazy/lazy.module.ts @@ -1,11 +1,15 @@ -import { NgModule } from '@angular/core'; +import { InjectionToken, NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UIRouterModule } from '@uirouter/angular'; import { LazyComponent } from './lazy.component'; export const states = [ { name: 'lazy', url: '/lazy', component: LazyComponent }, - { name: 'lazy.child', url: '/child', component: LazyComponent }, + { + name: 'lazy.child', + url: '/child', + loadComponent: () => import("./lazy2.component").then(m => m.Lazy2Component) + }, { name: 'lazy.child.viewtarget', url: '/viewtarget', @@ -16,8 +20,16 @@ export const states = [ }, ]; +export const LAZY_PROVIDER_TOKE = new InjectionToken("lazyProvider"); + @NgModule({ imports: [CommonModule, UIRouterModule.forChild({ states: states })], + providers: [ + { + provide: LAZY_PROVIDER_TOKE, + useValue: "provided value" + } + ], declarations: [LazyComponent], }) export class LazyModule {} diff --git a/test-angular-versions/v19-standalone/src/app/lazy/lazy2.component.ts b/test-angular-versions/v19-standalone/src/app/lazy/lazy2.component.ts new file mode 100644 index 000000000..aafa6574c --- /dev/null +++ b/test-angular-versions/v19-standalone/src/app/lazy/lazy2.component.ts @@ -0,0 +1,18 @@ +import { Component, inject, input } from '@angular/core'; +import { Ng2StateDeclaration, UIRouterModule } from '@uirouter/angular'; +import { LAZY_PROVIDER_TOKE } from './lazy.module'; + +@Component({ + selector: 'app-lazy', + standalone: true, + imports: [UIRouterModule], + template: ` +

{{ state().name }} works!

+

{{ _providedString }}

+ + `, +}) +export class Lazy2Component { + state = input.required({ alias: '$state$' }); + _providedString = inject(LAZY_PROVIDER_TOKE); +} diff --git a/test/loadComponent/bar/bar.component.ts b/test/loadComponent/bar/bar.component.ts new file mode 100644 index 000000000..fcbd17f23 --- /dev/null +++ b/test/loadComponent/bar/bar.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bar", + template: "BAR", + standalone: false +}) +export class BarComponent {} diff --git a/test/loadComponent/foo/foo.component.ts b/test/loadComponent/foo/foo.component.ts new file mode 100644 index 000000000..2b94c7e62 --- /dev/null +++ b/test/loadComponent/foo/foo.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "foo", + template: "FOO", + standalone: true +}) +export class FooComponent {} diff --git a/test/loadComponent/loadComponent.spec.ts b/test/loadComponent/loadComponent.spec.ts new file mode 100644 index 000000000..2a71e0d67 --- /dev/null +++ b/test/loadComponent/loadComponent.spec.ts @@ -0,0 +1,66 @@ +import { memoryLocationPlugin, UIRouter } from "@uirouter/core"; +import { UIRouterModule } from "../../src/uiRouterNgModule"; +import { inject, TestBed, waitForAsync } from "@angular/core/testing"; +import { UIView } from "../../src/directives/uiView"; +import { Ng2StateDeclaration } from "../../src/interface"; + +const fooState = { + name: 'foo', + url: '/foo', + loadComponent: () => import("./foo/foo.component").then(result => result.FooComponent) +}; + +const barState = { + name: 'bar', + url: '/bar', + loadComponent: () => import("./bar/bar.component").then(result => result.BarComponent) +}; + +function configFn(router: UIRouter) { + router.plugin(memoryLocationPlugin); +} + +describe('lazy loading', () => { + + beforeEach(() => { + const routerModule = UIRouterModule.forRoot({ useHash: true, states: [], config: configFn }); + TestBed.configureTestingModule({ + declarations: [], + imports: [routerModule] + }); + }); + + it('should lazy load a standalone component', waitForAsync( + inject([UIRouter], ({ stateRegistry, stateService, globals }: UIRouter) => { + stateRegistry.register(fooState); + const fixture = TestBed.createComponent(UIView); + fixture.detectChanges(); + const names = stateRegistry.get().map(state => state.name).sort(); + expect(names.length).toBe(2); + expect(names).toEqual(['', 'foo']); + + stateService.go('foo') + .then(() => { + expect(globals.current.name).toBe('foo'); + expect((globals.current as Ng2StateDeclaration).component).toBeTruthy(); + const innerText = fixture.debugElement.nativeElement.textContent.replace(/\s+/g, ' ').trim(); + expect(innerText).toBe('FOO'); + }); + }) + )); + + it('should throw error if component is not standalone', waitForAsync( + inject([UIRouter], ({ stateRegistry, stateService }: UIRouter) => { + stateRegistry.register(barState); + const fixture = TestBed.createComponent(UIView); + fixture.detectChanges(); + const names = stateRegistry.get().map(state => state.name).sort(); + expect(names.length).toBe(2); + expect(names).toEqual(['', 'bar']); + + const success = () => { throw Error('success not expected') }; + const error = err => expect(err.detail.message).toBe("Is not a standalone component."); + stateService.go('bar').then(success, error); + }) + )); +}); diff --git a/test/uiView/resolveBinding.spec.ts b/test/uiView/resolveBinding.spec.ts index 949a8d657..3f7f480e2 100644 --- a/test/uiView/resolveBinding.spec.ts +++ b/test/uiView/resolveBinding.spec.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Inject, input, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Ng2StateDeclaration, UIRouterModule, UIView } from '../../src'; import { By } from '@angular/platform-browser'; @@ -18,6 +18,14 @@ describe('uiView', () => { @Input('resolve3') _resolve3; @Input('resolve4') _resolve4; @Input() resolve5; + @Input({ alias: 'resolve6' }) _resolve6; + @Input({ alias: 'resolve7' }) _resolve7; + @Input({ transform: (value: string) => `${value}1` }) resolve8; + resolve9 = input(""); + resolve10 = input(""); + _resolve11 = input("", { alias: 'resolve11' }); + _resolve12 = input("", { alias: 'resolve12' }); + resolve13 = input("", { transform: value => `${value}1` }); } let comp: ManyResolvesComponent; @@ -32,6 +40,9 @@ describe('uiView', () => { // component_input: 'resolve name' resolve2: 'Resolve2', resolve4: 'Resolve4', + resolve7: 'Resolve7', + resolve10: 'Resolve10', + resolve12: 'Resolve12' }, resolve: [ { token: 'resolve1', resolveFn: () => 'resolve1' }, @@ -39,6 +50,14 @@ describe('uiView', () => { { token: 'resolve3', resolveFn: () => 'resolve3' }, { token: 'Resolve4', resolveFn: () => 'resolve4' }, new Resolvable('resolve5', () => 'resolve5', [], { async: 'NOWAIT' }), + { token: 'resolve6', resolveFn: () => 'resolve6' }, + { token: 'Resolve7', resolveFn: () => 'resolve7' }, + { token: 'resolve8', resolveFn: () => 'resolve8' }, + { token: 'resolve9', resolveFn: () => 'resolve9' }, + { token: 'Resolve10', resolveFn: () => 'resolve10' }, + { token: 'resolve11', resolveFn: () => 'resolve11' }, + { token: 'Resolve12', resolveFn: () => 'resolve12' }, + { token: 'resolve13', resolveFn: () => 'resolve13' } ], }; @@ -80,6 +99,38 @@ describe('uiView', () => { expect(typeof comp.resolve5.then).toBe('function'); }); + it('should bind resolve by alias to component input templateName', () => { + expect(comp._resolve6).toBe('resolve6'); + }); + + it('should bind resolve by alias to the component input templateName specified in state `bindings`', () => { + expect(comp._resolve7).toBe('resolve7'); + }); + + it('should bind resolve to the component input name and transform its value', () => { + expect(comp.resolve8).toBe('resolve81'); + }); + + it('should bind resolve by name to component input signal name', () => { + expect(comp.resolve9()).toBe('resolve9'); + }); + + it('should bind resolve by name to the component input signal specified by `bindings`', () => { + expect(comp.resolve10()).toBe('resolve10'); + }); + + it('should bind resolve by name to component input signal templateName', () => { + expect(comp._resolve11()).toBe('resolve11'); + }); + + it('should bind resolve by name to the component input signal templateName specified in state `bindings`', () => { + expect(comp._resolve12()).toBe('resolve12'); + }); + + it('should bind resolve to the component input signal name and transform its value', () => { + expect(comp.resolve13()).toBe('resolve131'); + }); + ///////////////////////////////////////// it('should inject resolve by name to constructor', () => {