diff --git a/src/core/application.ts b/src/core/application.ts index b8b92715..d707ff43 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -7,6 +7,8 @@ import { Router } from "./router" import { Schema, defaultSchema } from "./schema" import { ActionDescriptorFilter, ActionDescriptorFilters, defaultActionDescriptorFilters } from "./action_descriptor" +export type AsyncConstructor = () => Promise + export class Application implements ErrorHandler { readonly element: Element readonly schema: Schema @@ -49,6 +51,10 @@ export class Application implements ErrorHandler { this.load({ identifier, controllerConstructor }) } + registerLazy(identifier: string, controllerConstructor: AsyncConstructor) { + this.router.registerLazyModule(identifier, controllerConstructor) + } + registerActionOption(name: string, filter: ActionDescriptorFilter) { this.actionDescriptorFilters[name] = filter } diff --git a/src/core/router.ts b/src/core/router.ts index d0d40a84..5f3099bf 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -1,4 +1,4 @@ -import { Application } from "./application" +import { Application, AsyncConstructor } from "./application" import { Context } from "./context" import { Definition } from "./definition" import { Module } from "./module" @@ -11,11 +11,13 @@ export class Router implements ScopeObserverDelegate { private scopeObserver: ScopeObserver private scopesByIdentifier: Multimap private modulesByIdentifier: Map + private lazyModulesByIdentifier: Map constructor(application: Application) { this.application = application this.scopeObserver = new ScopeObserver(this.element, this.schema, this) this.scopesByIdentifier = new Multimap() + this.lazyModulesByIdentifier = new Map() this.modulesByIdentifier = new Map() } @@ -98,10 +100,13 @@ export class Router implements ScopeObserverDelegate { } scopeConnected(scope: Scope) { - this.scopesByIdentifier.add(scope.identifier, scope) + const { identifier } = scope + this.scopesByIdentifier.add(identifier, scope) const module = this.modulesByIdentifier.get(scope.identifier) if (module) { module.connectContextForScope(scope) + } else if (this.lazyModulesByIdentifier.has(identifier)) { + this.loadLazyModule(identifier) } } @@ -115,6 +120,14 @@ export class Router implements ScopeObserverDelegate { // Modules + registerLazyModule(identifier: string, controllerConstructor: AsyncConstructor) { + if (!this.modulesByIdentifier.has(identifier) && !this.lazyModulesByIdentifier.has(identifier)) { + this.lazyModulesByIdentifier.set(identifier, controllerConstructor) + } else { + this.application.logger.warn(`Stimulus has already a controller with "${identifier}" registered.`) + } + } + private connectModule(module: Module) { this.modulesByIdentifier.set(module.identifier, module) const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) @@ -126,4 +139,22 @@ export class Router implements ScopeObserverDelegate { const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) scopes.forEach((scope) => module.disconnectContextForScope(scope)) } + + private loadLazyModule(identifier: string) { + const callback = this.lazyModulesByIdentifier.get(identifier) + if (callback && typeof callback === "function") { + callback().then((controllerConstructor) => { + if (!this.modulesByIdentifier.has(identifier)) { + this.loadDefinition({ identifier, controllerConstructor }) + this.lazyModulesByIdentifier.delete(identifier) + } + }) + } else { + this.application.logger.warn( + `Stimulus expected the callback registered for "${identifier}" to resolve to a controllerConstructor but didn't`, + `Failed to lazy load ${identifier}`, + { identifier } + ) + } + } } diff --git a/src/tests/modules/core/error_handler_tests.ts b/src/tests/modules/core/error_handler_tests.ts index d94482ea..00a956c1 100644 --- a/src/tests/modules/core/error_handler_tests.ts +++ b/src/tests/modules/core/error_handler_tests.ts @@ -2,7 +2,7 @@ import { Controller } from "../../../core/controller" import { Application } from "../../../core/application" import { ControllerTestCase } from "../../cases/controller_test_case" -class MockLogger { +export class MockLogger { errors: any[] = [] logs: any[] = [] warns: any[] = [] diff --git a/src/tests/modules/core/lazy_loading_tests.ts b/src/tests/modules/core/lazy_loading_tests.ts new file mode 100644 index 00000000..b82548bc --- /dev/null +++ b/src/tests/modules/core/lazy_loading_tests.ts @@ -0,0 +1,28 @@ +import { ApplicationTestCase } from "../../cases" +import { Controller } from "../../../core" +import { MockLogger } from "./error_handler_tests" + +class LazyController extends Controller { + connect() { + this.application.logger.log("Hello from lazy controller") + } +} + +export default class LazyLoadingTests extends ApplicationTestCase { + async setupApplication() { + this.application.logger = new MockLogger() + + this.application.registerLazy("lazy", () => new Promise((resolve, _reject) => resolve(LazyController))) + } + + get mockLogger(): MockLogger { + return this.application.logger as any + } + + async "test lazy loading of controllers"() { + await this.renderFixture(`
`) + + this.assert.equal(this.mockLogger.logs.length, 2) + this.mockLogger.logs.forEach((entry) => this.assert.equal(entry, "Hello from lazy controller")) + } +}