|
| 1 | +Dependency Injection |
| 2 | +==================== |
| 3 | + |
| 4 | +The NativeScript CLI is migrating from name-based dependency injection (the |
| 5 | +`$injector` global, where a constructor parameter named `$doctorService` |
| 6 | +resolves the service registered under the string `"doctorService"`) to a typed, |
| 7 | +token-based container. Both APIs are backed by **one container**, so they can be |
| 8 | +mixed freely: a service registered under a legacy string name is resolvable |
| 9 | +through its typed token and vice versa. The legacy `$injector` surface remains |
| 10 | +fully supported, is marked `@deprecated` in-editor, and its usage is traced at |
| 11 | +runtime so removal can be staged over releases. |
| 12 | + |
| 13 | +Inside the CLI, import from `lib/common/di`. Extension and hook authors import |
| 14 | +the same API from the `nativescript/contracts` subpath (see |
| 15 | +[For extension and hook authors](#for-extension-and-hook-authors)). |
| 16 | + |
| 17 | +At a glance |
| 18 | +----------- |
| 19 | + |
| 20 | +```ts |
| 21 | +import { inject, DoctorService } from "nativescript/contracts"; |
| 22 | + |
| 23 | +class PlatformChecker { |
| 24 | + private doctorService = inject(DoctorService); // typed, no decorators needed |
| 25 | + |
| 26 | + async check(projectDir: string): Promise<boolean> { |
| 27 | + return this.doctorService.canExecuteLocalBuild({ projectDir }); |
| 28 | + } |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +Services that have no typed token yet remain reachable by their registry name — |
| 33 | +`inject("logger")` — but that is the migration bridge, not the API: prefer the |
| 34 | +token wherever one exists, and mint a token rather than a new string name. |
| 35 | + |
| 36 | +Tokens: `@Contract` |
| 37 | +------------------- |
| 38 | + |
| 39 | +A token is an abstract class annotated with `@Contract`. The class is both the |
| 40 | +compile-time type and the runtime lookup key; the decorator records the token's |
| 41 | +canonical string name: |
| 42 | + |
| 43 | +```ts |
| 44 | +import { Contract } from "nativescript/contracts"; |
| 45 | + |
| 46 | +@Contract({ name: "doctorService" }) // canonical form, no `$` |
| 47 | +export abstract class DoctorService { |
| 48 | + abstract canExecuteLocalBuild(configuration?: { |
| 49 | + platform?: string; |
| 50 | + projectDir?: string; |
| 51 | + }): Promise<boolean>; |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +Rules: |
| 56 | + |
| 57 | +- **The name is an explicit string literal** — never derived from `class.name`, |
| 58 | + which changes under minification. |
| 59 | +- Names are minted at this single choke point: declaring two contracts with the |
| 60 | + same name **throws at load time**, because a duplicate would silently alias |
| 61 | + two tokens. |
| 62 | +- The options object leaves room for future fields without changing call sites. |
| 63 | +- Implementations do not become tokens by extending or implementing a contract; |
| 64 | + only the decorated class itself is a token. |
| 65 | + |
| 66 | +Resolving: `inject()` and `Injector` |
| 67 | +------------------------------------ |
| 68 | + |
| 69 | +`inject(token)` returns the singleton for a token from the current injection |
| 70 | +context. It is **synchronous by design** and valid only: |
| 71 | + |
| 72 | +- in field initializers, |
| 73 | +- in constructor bodies, |
| 74 | +- in provider factories, |
| 75 | +- inside an explicit `runInInjectionContext(injector, fn)`. |
| 76 | + |
| 77 | +It is **not** valid after an `await`. For late or conditional lookups, |
| 78 | +self-inject the `Injector` and use `get()`: |
| 79 | + |
| 80 | +```ts |
| 81 | +import { inject, Injector } from "nativescript/contracts"; |
| 82 | + |
| 83 | +class EnvironmentChecker { |
| 84 | + private injector = inject(Injector); |
| 85 | + |
| 86 | + async check(projectDir: string) { |
| 87 | + await somethingAsync(); |
| 88 | + return this.injector.get(DoctorService); // fine after await |
| 89 | + } |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed |
| 94 | +string name — all three return the same instance. The string forms exist for |
| 95 | +interoperability with the legacy registry; use the class token whenever one |
| 96 | +exists. |
| 97 | + |
| 98 | +Registering: providers |
| 99 | +---------------------- |
| 100 | + |
| 101 | +```ts |
| 102 | +import { provide, provideLazy, Injector } from "nativescript/contracts"; |
| 103 | + |
| 104 | +const injector = new Injector([ |
| 105 | + // eager class binding; type-checked: the impl must satisfy the token |
| 106 | + provide(DoctorService, DoctorServiceImpl), |
| 107 | + |
| 108 | + // deferred loading: the module is require()d on first resolution only |
| 109 | + provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl), |
| 110 | + |
| 111 | + { provide: Config, useValue: { DISABLE_HOOKS: false } }, |
| 112 | + { provide: Dispatcher, useFactory: () => createDispatcher(), shared: false }, |
| 113 | +]); |
| 114 | + |
| 115 | +// registration is also allowed after construction; re-registering a token |
| 116 | +// updates the existing record in place |
| 117 | +injector.register(provide(ProjectNameService, ProjectNameServiceImpl)); |
| 118 | +``` |
| 119 | + |
| 120 | +Provider kinds: |
| 121 | + |
| 122 | +| Kind | Shape | Notes | |
| 123 | +|---|---|---| |
| 124 | +| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields | |
| 125 | +| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy | |
| 126 | +| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance | |
| 127 | +| Factory | `{ provide, useFactory }` | called inside an injection context | |
| 128 | + |
| 129 | +`shared: false` makes a provider transient: every resolution constructs a fresh |
| 130 | +instance. Transient instances are still retained by the container so |
| 131 | +`dispose()` reaches them. |
| 132 | + |
| 133 | +String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`) |
| 134 | +— that is how the legacy facade registers, and how per-call overrides address |
| 135 | +not-yet-migrated dependencies. New registrations should mint a `@Contract` |
| 136 | +token instead of a new string name. |
| 137 | + |
| 138 | +For per-call construction with overrides (a fresh instance of a class with some |
| 139 | +dependencies replaced), use `createInstance`: |
| 140 | + |
| 141 | +```ts |
| 142 | +const debugService = injector.createInstance(IOSDeviceDebugService, [ |
| 143 | + { provide: "device", useValue: device }, |
| 144 | +]); |
| 145 | +``` |
| 146 | + |
| 147 | +Overrides shadow **one level deep only** — the direct dependencies of the class |
| 148 | +being constructed. Nested dependencies are constructed by the injector that |
| 149 | +owns them and never see the per-call providers. |
| 150 | + |
| 151 | +Resolution semantics |
| 152 | +-------------------- |
| 153 | + |
| 154 | +- Lookup is **class object first, token name on a miss**, checked per injector |
| 155 | + level before delegating to the parent. Both keys index the same provider |
| 156 | + record, so re-registering a service by its string name (as plugins are |
| 157 | + documented to do with `$logger`) stays visible to `inject(Logger)` consumers. |
| 158 | +- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")` |
| 159 | + are the same registration. |
| 160 | +- The name fallback also makes **duplicated contract copies interchangeable**: |
| 161 | + if an extension's dependency tree carries its own copy of a contract class, |
| 162 | + that copy resolves to the same provider by name. "Works locally, breaks when |
| 163 | + installed" is not a failure mode of this design. |
| 164 | +- Cyclic dependencies fail with the full resolution path |
| 165 | + (`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`). |
| 166 | + |
| 167 | +Child scopes |
| 168 | +------------ |
| 169 | + |
| 170 | +`injector.createChild(providers)` creates a scope that shadows its parent for |
| 171 | +the given tokens and falls through for everything else. Sibling scopes are |
| 172 | +isolated. Scopes are how per-invocation data (hook payloads, per-call |
| 173 | +overrides) is layered over the shared singletons without ever entering the |
| 174 | +root container. |
| 175 | + |
| 176 | +`forwardRef` |
| 177 | +------------ |
| 178 | + |
| 179 | +Provider arrays are evaluated at module load. When a token is declared later in |
| 180 | +the same file (TDZ) or reached through a circular import, wrap the reference in |
| 181 | +a thunk; it is read only when the injector processes the provider: |
| 182 | + |
| 183 | +```ts |
| 184 | +import { forwardRef } from "nativescript/contracts"; |
| 185 | + |
| 186 | +const providers = [ |
| 187 | + { provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl }, |
| 188 | +]; |
| 189 | +``` |
| 190 | + |
| 191 | +`forwardRef` defers *references*, not construction — it cannot break an |
| 192 | +instantiation cycle between two services. For that, self-inject the `Injector` |
| 193 | +and resolve late (see above). |
| 194 | + |
| 195 | +Working alongside the legacy `$injector` |
| 196 | +---------------------------------------- |
| 197 | + |
| 198 | +The `Yok` facade (`global.$injector`) delegates to the token-based container, |
| 199 | +exposed as `$injector.di`. Everything is one registry: |
| 200 | + |
| 201 | +```ts |
| 202 | +$injector.resolve("doctorService") === $injector.di.get(DoctorService); // true |
| 203 | +``` |
| 204 | + |
| 205 | +- Legacy string names are permanent: a contract's token name is its interop |
| 206 | + identity, used by hooks, plugins, and the public API. Nothing is deleted |
| 207 | + per-service. |
| 208 | +- Every legacy member (`resolve`, `register`, `require*`, the command-registry |
| 209 | + surface) carries `@deprecated` JSDoc naming its replacement. |
| 210 | +- Legacy usage at the external entry points (param-name hooks, require-time |
| 211 | + extension registration, help templating) is reported through a deprecation |
| 212 | + tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or |
| 213 | + `NS_DEPRECATIONS=error` to preview the stricter stages that later releases |
| 214 | + will default to. |
| 215 | + |
| 216 | +For extension and hook authors |
| 217 | +------------------------------ |
| 218 | + |
| 219 | +Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency` |
| 220 | +for local development) and import from the `contracts` subpath: |
| 221 | + |
| 222 | +```ts |
| 223 | +import { inject, DoctorService } from "nativescript/contracts"; |
| 224 | +``` |
| 225 | + |
| 226 | +- The subpath resolves through a directory `package.json` — the CLI's |
| 227 | + `package.json` deliberately has **no `exports` map**, so any deep `require()` |
| 228 | + paths you already use keep working. |
| 229 | +- The entry point is side-effect-free: importing it never boots a CLI runtime, |
| 230 | + even from a duplicated copy in your dependency tree. |
| 231 | +- The existing `$injector`-based extension and hook mechanisms keep working |
| 232 | + unchanged; the typed API is additive. |
| 233 | + |
| 234 | +Available contracts |
| 235 | +------------------- |
| 236 | + |
| 237 | +The first tranche, growing as services migrate: |
| 238 | + |
| 239 | +| Token | Legacy name | |
| 240 | +|---|---| |
| 241 | +| `DoctorService` | `doctorService` | |
| 242 | +| `ProjectNameService` | `projectNameService` | |
| 243 | + |
| 244 | +Legacy → new quick reference |
| 245 | +---------------------------- |
| 246 | + |
| 247 | +`di` below is the token-based container backing the facade (`$injector.di`), |
| 248 | +or any `Injector` you hold directly. |
| 249 | + |
| 250 | +| Legacy (`$injector`) | New | |
| 251 | +|---|---| |
| 252 | +| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` | |
| 253 | +| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` | |
| 254 | +| `register("name", Impl)` | `di.register(provide(Token, Impl))` | |
| 255 | +| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` | |
| 256 | +| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` | |
| 257 | +| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` | |
| 258 | +| constructor param `$name` | `inject(Token)` field initializer | |
0 commit comments