diff --git a/documentation/docs/documentation/usage/Classes.mdx b/documentation/docs/documentation/usage/Classes.mdx index ca285641..9180b52d 100644 --- a/documentation/docs/documentation/usage/Classes.mdx +++ b/documentation/docs/documentation/usage/Classes.mdx @@ -22,7 +22,29 @@ Constructor injection is the preferred way to inject dependencies. It is more ex ::: ## Delayed injection -Dependencies annotated with the `@Inject` annotation are resolved immediately **after** the constructor is called. If you want to inject a class at a later point in time, you can use the `@LateInject` annotation instead, and inject the dependencies by manually with the `Obsidian.inject()` function. +Dependencies annotated with the `@Inject` annotation are resolved immediately **after** the constructor is called. Obsidian supports two methods to delay injection: `@LazyInject` and `@LateInject`. + +### @LazyInject +Class fields annotated with the `@LazyInject` annotation are resolved lazily when they are accessed for the first time. + +```ts +import {Injectable, LazyInject} from 'react-obsidian'; +import {ApplicationGraph} from './ApplicationGraph'; + +@Injectable(ApplicationGraph) +export class MyClass { + @LazyInject() private httpClient!: HttpClient; + + public init() { + // HttpClient is hasn't been used yet, so it's undefined + + this.httpClient.get('https://www.wix.com'); // HttpClient is resolved once it's accessed + } +} +``` + +### @LateInject +Use the `@LateInject` annotation to delay the injection of dependencies until a later point in time determined by the user. ```ts import {Injectable, LateInject} from 'react-obsidian'; diff --git a/src/decorators/inject/LazyInject.ts b/src/decorators/inject/LazyInject.ts new file mode 100644 index 00000000..aeef8cbd --- /dev/null +++ b/src/decorators/inject/LazyInject.ts @@ -0,0 +1,12 @@ +import InjectionMetadata from '../../injectors/class/InjectionMetadata'; + +export function LazyInject(name?: string) { + return ( + target: Object | any, + _propertyKey?: string, + _indexOrPropertyDescriptor?: number | PropertyDescriptor, + ) => { + const metadata = new InjectionMetadata(); + metadata.saveLazyPropertyMetadata(target.constructor, name!); + }; +} diff --git a/src/index.ts b/src/index.ts index 2942eafd..543646cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { Provides } from './decorators/provides/Provides'; export { Injectable } from './decorators/inject/Injectable'; export { Inject } from './decorators/inject/Inject'; export { LateInject } from './decorators/inject/LateInject'; +export { LazyInject } from './decorators/inject/LazyInject'; export { LifecycleBound } from './decorators/LifecycleBound'; export { GraphMiddleware } from './graph/registry/GraphMiddleware'; export { GraphResolveChain as ResolveChain } from './graph/registry/GraphResolveChain'; diff --git a/src/injectors/class/ClassInjector.ts b/src/injectors/class/ClassInjector.ts index 1d0608f2..bdca2a8e 100644 --- a/src/injectors/class/ClassInjector.ts +++ b/src/injectors/class/ClassInjector.ts @@ -30,7 +30,10 @@ export default class ClassInjector { const argsToInject = this.injectConstructorArgs(args, graph, target); graph.onBind(target); const createdObject = Reflect.construct(target, argsToInject, newTarget); + this.injectProperties(target, createdObject, graph); + this.injectLazyProperties(target, createdObject, graph); + const originalComponentWillUnmount = createdObject.componentWillUnmount; createdObject.componentWillUnmount = () => { originalComponentWillUnmount?.(); @@ -52,6 +55,25 @@ export default class ClassInjector { Reflect.set(createdObject, key, graph.retrieve(key)); }); } + + private injectLazyProperties(target: any, createdObject: any, graph: Graph) { + const lazyProperties = injectionMetadata.getLazyPropertiesToInject(target); + + lazyProperties.forEach((key) => { + Object.defineProperty(createdObject, key, { + get: () => { + const value = graph.retrieve(key); + Object.defineProperty(createdObject, key, { + value, + writable: true, + configurable: true, + }); + return value; + }, + configurable: true, + }); + }); + } }(); } } diff --git a/src/injectors/class/InjectionMetadata.ts b/src/injectors/class/InjectionMetadata.ts index c675ca34..a1ed78f3 100644 --- a/src/injectors/class/InjectionMetadata.ts +++ b/src/injectors/class/InjectionMetadata.ts @@ -4,6 +4,7 @@ export default class InjectionMetadata { private readonly injectionMetadataKey = 'injectionMetadata'; private readonly injectedConstructorArgsKey = 'injectedConstructorArgsKey'; private readonly lateInjectionMetadataKey = 'lateInjectionMetadataKey'; + private readonly lazyInjectionMetadataKey = 'lazyInjectionMetadataKey'; getConstructorArgsToInject(target: any): ConstructorArgs { return Reflect.getMetadata(this.injectedConstructorArgsKey, target) ?? new ConstructorArgs(); @@ -17,6 +18,10 @@ export default class InjectionMetadata { return this.getProperties(this.lateInjectionMetadataKey, target); } + getLazyPropertiesToInject(target: any): Set { + return this.getProperties(this.lazyInjectionMetadataKey, target); + } + saveConstructorParamMetadata(target: any, paramName: string, index: number) { const argsToInject = this.getConstructorArgsToInject(target); argsToInject.add(paramName, index); @@ -43,6 +48,14 @@ export default class InjectionMetadata { ); } + saveLazyPropertyMetadata(target: any, property: string) { + this.saveProperties( + this.lazyInjectionMetadataKey, + this.getLazyPropertiesToInject(target).add(property), + target, + ); + } + private saveProperties(key: string, properties: Set, target: any) { Reflect.defineMetadata( key, diff --git a/test/acceptance/lazyInject.test.ts b/test/acceptance/lazyInject.test.ts new file mode 100644 index 00000000..3dfacf45 --- /dev/null +++ b/test/acceptance/lazyInject.test.ts @@ -0,0 +1,22 @@ +import { DependencyTrackingGraph, SOME_STRING } from '../fixtures/DependencyTrackingGraph'; +import { Injectable, LazyInject, Obsidian } from '../../src'; + +describe('Lazy inject', () => { + it('does not create dependencies before they are used', () => { + // eslint-disable-next-line no-new + new Container(); + + const graph = Obsidian.obtain(DependencyTrackingGraph); + expect(graph.createdDependencies().has(SOME_STRING)).toBe(false); + }); + + it('creates dependencies when they are used', () => { + const container = new Container(); + expect(container.someString).toBe(SOME_STRING); + }); +}); + +@Injectable(DependencyTrackingGraph) +class Container { + @LazyInject() someString!: string; +} diff --git a/test/fixtures/DependencyTrackingGraph.ts b/test/fixtures/DependencyTrackingGraph.ts new file mode 100644 index 00000000..2aed8d13 --- /dev/null +++ b/test/fixtures/DependencyTrackingGraph.ts @@ -0,0 +1,24 @@ +import { + Graph, + ObjectGraph, + Provides, + Singleton, +} from '../../src'; + +export const SOME_STRING = 'This is some dependency'; + +@Singleton() @Graph() +export class DependencyTrackingGraph extends ObjectGraph { + private readonly createdDependenciesInternal = new Set(); + + @Provides() + someString(): string { + this.createdDependenciesInternal.add('someDependency'); + return SOME_STRING; + } + + @Provides() + createdDependencies(): Set { + return this.createdDependenciesInternal; + } +} diff --git a/transformers/babel-plugin-obsidian/__snapshots__/index.test.ts.snap b/transformers/babel-plugin-obsidian/__snapshots__/index.test.ts.snap index ed5eb6f8..a76c4e22 100644 --- a/transformers/babel-plugin-obsidian/__snapshots__/index.test.ts.snap +++ b/transformers/babel-plugin-obsidian/__snapshots__/index.test.ts.snap @@ -43,6 +43,20 @@ let MainGraph = (_dec = LateInject("someString"), (_class = class MainGraph { })), _class));" `; +exports[`Provider Arguments Transformer Adds property name to @LazyInject arguments @LazyInject -> @LazyInject("myDependency") 1`] = ` +"var _dec, _class, _descriptor; +function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { var desc = {}; Object.keys(descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); desc.enumerable = !!desc.enumerable; desc.configurable = !!desc.configurable; if ('value' in desc || desc.initializer) { desc.writable = true; } desc = decorators.slice().reverse().reduce(function (desc, decorator) { return decorator(target, property, desc) || desc; }, desc); if (context && desc.initializer !== void 0) { desc.value = desc.initializer ? desc.initializer.call(context) : void 0; desc.initializer = undefined; } if (desc.initializer === void 0) { Object.defineProperty(target, property, desc); desc = null; } return desc; } +function _initializerWarningHelper(descriptor, context) { throw new Error('Decorating class property failed. Please ensure that ' + 'transform-class-properties is enabled and runs after the decorators transform.'); } +let MainGraph = (_dec = LazyInject("someString"), (_class = class MainGraph { + someString = _initializerWarningHelper(_descriptor, this); +}, (_descriptor = _applyDecoratedDescriptor(_class.prototype, "someString", [_dec], { + configurable: true, + enumerable: true, + writable: true, + initializer: null +})), _class));" +`; + exports[`Provider Arguments Transformer Does not add name if name is provided by the user 1`] = ` "var _dec, _class; function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { var desc = {}; Object.keys(descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); desc.enumerable = !!desc.enumerable; desc.configurable = !!desc.configurable; if ('value' in desc || desc.initializer) { desc.writable = true; } desc = decorators.slice().reverse().reduce(function (desc, decorator) { return decorator(target, property, desc) || desc; }, desc); if (context && desc.initializer !== void 0) { desc.value = desc.initializer ? desc.initializer.call(context) : void 0; desc.initializer = undefined; } if (desc.initializer === void 0) { Object.defineProperty(target, property, desc); desc = null; } return desc; } @@ -85,6 +99,20 @@ let MainGraph = (_dec = LateInject("someString"), (_class = class MainGraph { })), _class));" `; +exports[`Provider Arguments Transformer Does not add property name to @LazyInject if name is provided by the user 1`] = ` +"var _dec, _class, _descriptor; +function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { var desc = {}; Object.keys(descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); desc.enumerable = !!desc.enumerable; desc.configurable = !!desc.configurable; if ('value' in desc || desc.initializer) { desc.writable = true; } desc = decorators.slice().reverse().reduce(function (desc, decorator) { return decorator(target, property, desc) || desc; }, desc); if (context && desc.initializer !== void 0) { desc.value = desc.initializer ? desc.initializer.call(context) : void 0; desc.initializer = undefined; } if (desc.initializer === void 0) { Object.defineProperty(target, property, desc); desc = null; } return desc; } +function _initializerWarningHelper(descriptor, context) { throw new Error('Decorating class property failed. Please ensure that ' + 'transform-class-properties is enabled and runs after the decorators transform.'); } +let MainGraph = (_dec = LazyInject("someString"), (_class = class MainGraph { + someString = _initializerWarningHelper(_descriptor, this); +}, (_descriptor = _applyDecoratedDescriptor(_class.prototype, "someString", [_dec], { + configurable: true, + enumerable: true, + writable: true, + initializer: null +})), _class));" +`; + exports[`Provider Arguments Transformer handles providers that have no arguments 1`] = ` "var _dec, _class; function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { var desc = {}; Object.keys(descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); desc.enumerable = !!desc.enumerable; desc.configurable = !!desc.configurable; if ('value' in desc || desc.initializer) { desc.writable = true; } desc = decorators.slice().reverse().reduce(function (desc, decorator) { return decorator(target, property, desc) || desc; }, desc); if (context && desc.initializer !== void 0) { desc.value = desc.initializer ? desc.initializer.call(context) : void 0; desc.initializer = undefined; } if (desc.initializer === void 0) { Object.defineProperty(target, property, desc); desc = null; } return desc; } diff --git a/transformers/babel-plugin-obsidian/index.test.ts b/transformers/babel-plugin-obsidian/index.test.ts index a8479950..680f3685 100644 --- a/transformers/babel-plugin-obsidian/index.test.ts +++ b/transformers/babel-plugin-obsidian/index.test.ts @@ -42,6 +42,14 @@ const namedLateInject = `class MainGraph { @LateInject('myDependency') someString; }`; +const unnamedLazyInject = `class MainGraph { + @LazyInject() someString; +}`; + +const namedLazyInject = `class MainGraph { + @LazyInject('myDependency') someString; +}`; + describe('Provider Arguments Transformer', () => { const uut: Function = providerArgumentsTransformer; @@ -75,6 +83,17 @@ describe('Provider Arguments Transformer', () => { expect(result?.code).toMatchSnapshot(); }); + // + it('Adds property name to @LazyInject arguments @LazyInject -> @LazyInject("myDependency")', () => { + const result = transformSync(unnamedLazyInject); + expect(result?.code).toMatchSnapshot(); + }); + + it('Does not add property name to @LazyInject if name is provided by the user', () => { + const result = transformSync(namedLazyInject); + expect(result?.code).toMatchSnapshot(); + }); + // it('Adds property name to @LateInject arguments @LateInject -> @LateInject("myDependency")', () => { const result = transformSync(unnamedLateInject); expect(result?.code).toMatchSnapshot(); diff --git a/transformers/babel-plugin-obsidian/index.ts b/transformers/babel-plugin-obsidian/index.ts index df257772..028d73ea 100644 --- a/transformers/babel-plugin-obsidian/index.ts +++ b/transformers/babel-plugin-obsidian/index.ts @@ -27,6 +27,7 @@ const internalVisitor = { enter({ node }: NodePath) { unmagler.saveClassProperty('Inject', node); unmagler.saveClassProperty('LateInject', node); + unmagler.saveClassProperty('LazyInject', node); }, }, Identifier: { diff --git a/yarn.lock b/yarn.lock index 16810065..e5058a23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2703,10 +2703,10 @@ eslint-plugin-react@^7.26.1: semver "^6.3.1" string.prototype.matchall "^4.0.8" -eslint-plugin-unused-imports@2.x.x: - version "2.0.0" - resolved "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-2.0.0.tgz" - integrity sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A== +eslint-plugin-unused-imports@3.1.x: + version "3.1.0" + resolved "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.1.0.tgz#db015b569d3774e17a482388c95c17bd303bc602" + integrity sha512-9l1YFCzXKkw1qtAru1RWUtG2EVDZY0a0eChKXcL+EZ5jitG7qxdctu4RnvhOJHv4xfmUf7h+JJPINlVpGhZMrw== dependencies: eslint-rule-composer "^0.3.0"