-
-
Notifications
You must be signed in to change notification settings - Fork 205
feat(di): token-based dependency injection behind the Yok facade (phase 1) #6099
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
Open
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
7fe0f09
feat(di): add a token-based injector with @Contract tokens
edusperoni be97b39
feat(di): trace usage of legacy APIs slated for deprecation
edusperoni 8666f62
fix(hooks): reach the global injector as a last resort in @hook
edusperoni ef83dde
refactor(yok): re-point the facade at the token-based injector
edusperoni f64ed6a
feat(contracts): ship nativescript/contracts with a first tranche
edusperoni b076c54
test: pin the published injector, hook, and extension contracts
edusperoni a23a901
docs: document the token-based injection API
edusperoni 55a507e
fix(yok): keep hierarchical dispatchers on the owning injector
edusperoni 9bc9739
refactor: reach the process-wide injector through getInjector()
edusperoni ec5de90
refactor(yok): type the di bridge on IInjector
edusperoni 81f542a
refactor(yok)!: the facade extends Injector instead of wrapping one
edusperoni da89071
refactor(yok): split the facade surface into subsystem contracts
edusperoni d839373
fix: address review findings on the DI foundation
edusperoni 235befd
feat(di): Angular-shaped InjectOptions on inject() and Injector.get()
edusperoni File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "main": "../lib/contracts/index.js", | ||
| "types": "../lib/contracts/index.d.ts" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| Dependency Injection | ||
| ==================== | ||
|
|
||
| The NativeScript CLI is migrating from name-based dependency injection (the | ||
| `$injector` global, where a constructor parameter named `$doctorService` | ||
| resolves the service registered under the string `"doctorService"`) to a typed, | ||
| token-based container. Both APIs are backed by **one container**, so they can be | ||
| mixed freely: a service registered under a legacy string name is resolvable | ||
| through its typed token and vice versa. The legacy `$injector` surface remains | ||
| fully supported, is marked `@deprecated` in-editor, and its usage is traced at | ||
| runtime so removal can be staged over releases. | ||
|
|
||
| Inside the CLI, import from `lib/common/di`. Extension and hook authors import | ||
| the same API from the `nativescript/contracts` subpath (see | ||
| [For extension and hook authors](#for-extension-and-hook-authors)). | ||
|
|
||
| At a glance | ||
| ----------- | ||
|
|
||
| ```ts | ||
| import { inject, DoctorService } from "nativescript/contracts"; | ||
|
|
||
| class PlatformChecker { | ||
| private doctorService = inject(DoctorService); // typed, no decorators needed | ||
|
|
||
| async check(projectDir: string): Promise<boolean> { | ||
| return this.doctorService.canExecuteLocalBuild({ projectDir }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Services that have no typed token yet remain reachable by their registry name — | ||
| `inject("logger")` — but that is the migration bridge, not the API: prefer the | ||
| token wherever one exists, and mint a token rather than a new string name. | ||
|
|
||
| Tokens: `@Contract` | ||
| ------------------- | ||
|
|
||
| A token is an abstract class annotated with `@Contract`. The class is both the | ||
| compile-time type and the runtime lookup key; the decorator records the token's | ||
| canonical string name: | ||
|
|
||
| ```ts | ||
| import { Contract } from "nativescript/contracts"; | ||
|
|
||
| @Contract({ name: "doctorService" }) // canonical form, no `$` | ||
| export abstract class DoctorService { | ||
| abstract canExecuteLocalBuild(configuration?: { | ||
| platform?: string; | ||
| projectDir?: string; | ||
| }): Promise<boolean>; | ||
| } | ||
| ``` | ||
|
|
||
| Rules: | ||
|
|
||
| - **The name is an explicit string literal** — never derived from `class.name`, | ||
| which changes under minification. | ||
| - Names are minted at this single choke point: declaring two contracts with the | ||
| same name **throws at load time**, because a duplicate would silently alias | ||
| two tokens. | ||
| - The options object leaves room for future fields without changing call sites. | ||
| - Implementations do not become tokens by extending or implementing a contract; | ||
| only the decorated class itself is a token. | ||
|
|
||
| Resolving: `inject()` and `Injector` | ||
| ------------------------------------ | ||
|
|
||
| `inject(token)` returns the singleton for a token from the current injection | ||
| context. It is **synchronous by design** and valid only: | ||
|
|
||
| - in field initializers, | ||
| - in constructor bodies, | ||
| - in provider factories, | ||
| - inside an explicit `runInInjectionContext(injector, fn)`. | ||
|
|
||
| It is **not** valid after an `await`. For late or conditional lookups, | ||
| self-inject the `Injector` and use `get()`: | ||
|
|
||
| ```ts | ||
| import { inject, Injector } from "nativescript/contracts"; | ||
|
|
||
| class EnvironmentChecker { | ||
| private injector = inject(Injector); | ||
|
|
||
| async check(projectDir: string) { | ||
| await somethingAsync(); | ||
| return this.injector.get(DoctorService); // fine after await | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `Injector.get()` accepts a contract class, a string name, or a `$`-prefixed | ||
| string name — all three return the same instance. The string forms exist for | ||
| interoperability with the legacy registry; use the class token whenever one | ||
| exists. | ||
|
|
||
| Both `inject()` and `get()` take Angular-shaped options as their second | ||
| argument: | ||
|
|
||
| ```ts | ||
| inject(DoctorService, { optional: true }); // DoctorService | null — no throw | ||
| inject("logger", { skipSelf: true }); // start at the parent: escapes a | ||
| // child scope's shadowing entry | ||
| inject("options", { self: true }); // this level only — no fallthrough | ||
| ``` | ||
|
|
||
| `optional` covers not-found only; a found-but-misconfigured provider still | ||
| throws. `self` and `skipSelf` cannot be combined. There is deliberately no | ||
| `host` option: it is an Angular component-tree concept with no analog in the | ||
| CLI's injector hierarchy. | ||
|
|
||
| Registering: providers | ||
| ---------------------- | ||
|
|
||
| ```ts | ||
| import { provide, provideLazy, Injector } from "nativescript/contracts"; | ||
|
|
||
| const injector = new Injector([ | ||
| // eager class binding; type-checked: the impl must satisfy the token | ||
| provide(DoctorService, DoctorServiceImpl), | ||
|
|
||
| // deferred loading: the module is require()d on first resolution only | ||
| provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl), | ||
|
|
||
| { provide: Config, useValue: { DISABLE_HOOKS: false } }, | ||
| { provide: Dispatcher, useFactory: () => createDispatcher(), shared: false }, | ||
| ]); | ||
|
|
||
| // registration is also allowed after construction; re-registering a token | ||
| // updates the existing record in place | ||
| injector.register(provide(ProjectNameService, ProjectNameServiceImpl)); | ||
| ``` | ||
|
|
||
| Provider kinds: | ||
|
|
||
| | Kind | Shape | Notes | | ||
| |---|---|---| | ||
| | Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields | | ||
| | Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy | | ||
| | Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance. With `shared: false` there is no resolver and `get()` throws — a preserved legacy quirk | | ||
| | Factory | `{ provide, useFactory }` | called inside an injection context | | ||
|
|
||
| `shared: false` makes a provider transient: every resolution constructs a fresh | ||
| instance. Transient instances are still retained by the container so | ||
| `dispose()` reaches them. | ||
|
|
||
| String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`) | ||
| — that is how the legacy facade registers, and how per-call overrides address | ||
| not-yet-migrated dependencies. New registrations should mint a `@Contract` | ||
| token instead of a new string name. | ||
|
|
||
| For per-call construction with overrides (a fresh instance of a class with some | ||
| dependencies replaced), use `createInstance`: | ||
|
|
||
| ```ts | ||
| const debugService = injector.createInstance(IOSDeviceDebugService, [ | ||
| { provide: "device", useValue: device }, | ||
| ]); | ||
| ``` | ||
|
|
||
| Overrides shadow **one level deep only** — the direct dependencies of the class | ||
| being constructed. Nested dependencies are constructed by the injector that | ||
| owns them and never see the per-call providers. | ||
|
|
||
| Resolution semantics | ||
| -------------------- | ||
|
|
||
| - Lookup is **class object first, token name on a miss**, checked per injector | ||
| level before delegating to the parent. Both keys index the same provider | ||
| record, so re-registering a service by its string name (as plugins are | ||
| documented to do with `$logger`) stays visible to `inject(Logger)` consumers. | ||
| - A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")` | ||
| are the same registration. | ||
| - The name fallback also makes **duplicated contract copies interchangeable**: | ||
| if an extension's dependency tree carries its own copy of a contract class, | ||
| that copy resolves to the same provider by name. "Works locally, breaks when | ||
| installed" is not a failure mode of this design. | ||
| - Cyclic dependencies fail with the full resolution path | ||
| (`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`). | ||
|
|
||
| Child scopes | ||
| ------------ | ||
|
|
||
| `injector.createChild(providers)` creates a scope that shadows its parent for | ||
| the given tokens and falls through for everything else. Sibling scopes are | ||
| isolated. Scopes are how per-invocation data (hook payloads, per-call | ||
| overrides) is layered over the shared singletons without ever entering the | ||
| root container. | ||
|
|
||
| `forwardRef` | ||
| ------------ | ||
|
|
||
| Provider arrays are evaluated at module load. When a token is declared later in | ||
| the same file (TDZ) or reached through a circular import, wrap the reference in | ||
| a thunk; it is read only when the injector processes the provider: | ||
|
|
||
| ```ts | ||
| import { forwardRef } from "nativescript/contracts"; | ||
|
|
||
| const providers = [ | ||
| { provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl }, | ||
| ]; | ||
| ``` | ||
|
|
||
| `forwardRef` defers *references*, not construction — it cannot break an | ||
| instantiation cycle between two services. For that, self-inject the `Injector` | ||
| and resolve late (see above). | ||
|
|
||
| Working alongside the legacy `$injector` | ||
| ---------------------------------------- | ||
|
|
||
| The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the | ||
| token-based container — so the new API works on it directly: | ||
|
|
||
| ```ts | ||
| $injector.resolve("doctorService") === $injector.get(DoctorService); // true | ||
| $injector.register(provide(DoctorService, DoctorServiceImpl)); | ||
| runInInjectionContext($injector, () => inject(DoctorService)); | ||
| ``` | ||
|
|
||
| - Legacy string names are permanent: a contract's token name is its interop | ||
| identity, used by hooks, plugins, and the public API. Nothing is deleted | ||
| per-service. | ||
| - Every legacy member (`resolve`, `register`, `require*`, the command-registry | ||
| surface) carries `@deprecated` JSDoc naming its replacement. | ||
| - Legacy usage at the external entry points (param-name hooks, require-time | ||
| extension registration, help templating) is reported through a deprecation | ||
| tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or | ||
| `NS_DEPRECATIONS=error` to preview the stricter stages that later releases | ||
| will default to. | ||
|
|
||
| For extension and hook authors | ||
| ------------------------------ | ||
|
|
||
| Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency` | ||
| for local development) and import from the `contracts` subpath: | ||
|
|
||
| ```ts | ||
| import { inject, DoctorService } from "nativescript/contracts"; | ||
| ``` | ||
|
|
||
| - The subpath resolves through a directory `package.json` — the CLI's | ||
| `package.json` deliberately has **no `exports` map**, so any deep `require()` | ||
| paths you already use keep working. | ||
| - The entry point is side-effect-free: importing it never boots a CLI runtime, | ||
| even from a duplicated copy in your dependency tree. | ||
| - The existing `$injector`-based extension and hook mechanisms keep working | ||
| unchanged; the typed API is additive. | ||
|
|
||
| Available contracts | ||
| ------------------- | ||
|
|
||
| The first tranche, growing as services migrate: | ||
|
|
||
| | Token | Legacy name | | ||
| |---|---| | ||
| | `DoctorService` | `doctorService` | | ||
| | `ProjectNameService` | `projectNameService` | | ||
|
|
||
| Legacy → new quick reference | ||
| ---------------------------- | ||
|
|
||
| `di` below is any `Injector` you hold — including `$injector` itself, which | ||
| extends `Injector`. | ||
|
|
||
| | Legacy (`$injector`) | New | | ||
| |---|---| | ||
| | `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` | | ||
| | `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` | | ||
| | `register("name", Impl)` | `di.register(provide(Token, Impl))` | | ||
| | `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` | | ||
| | `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` | | ||
| | `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` | | ||
| | constructor param `$name` | `inject(Token)` field initializer | | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { Contract } from "../di/contract"; | ||
| import type { ICommand } from "../definitions/commands"; | ||
|
|
||
| /** | ||
| * The command-registry face of the injector facade. Transitional contract: it | ||
| * mirrors what consumers call today, so that extracting the registry from the | ||
| * facade later is a provider swap for this token, not a consumer migration. | ||
| * Members slated for replacement keep their deprecation markers. | ||
| */ | ||
| @Contract({ name: "commandRegistry" }) | ||
| export abstract class CommandRegistry { | ||
| /** | ||
| * @deprecated Path-based command registration; slated for replacement by | ||
| * manifest-declared commands. | ||
| */ | ||
| abstract requireCommand(names: string | string[], file: string): void; | ||
| abstract registerCommand(names: string | string[], resolver: any): void; | ||
| abstract resolveCommand(name: string): ICommand; | ||
| abstract getRegisteredCommandsNames(includeDev: boolean): string[]; | ||
| abstract getChildrenCommandsNames(commandName: string): string[]; | ||
| abstract buildHierarchicalCommand( | ||
| parentCommandName: string, | ||
| commandLineArguments: string[], | ||
| ): any; | ||
| /** Side-effecting: fails with help output on a bad subcommand. */ | ||
| abstract isValidHierarchicalCommand( | ||
| commandName: string, | ||
| commandArguments: string[], | ||
| ): Promise<boolean>; | ||
| abstract isDefaultCommand(commandName: string): boolean; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // Internal subsystem contracts of the injector facade. Deliberately NOT | ||
| // re-exported from nativescript/contracts: promoting one to the public | ||
| // surface is a one-line decision that should be made per contract, not by | ||
| // default. Each token resolves to the facade itself until its subsystem is | ||
| // physically extracted — at which point the provider is swapped and consumers | ||
| // keep working unchanged. | ||
| export { CommandRegistry } from "./command-registry"; | ||
| export { KeyCommandRegistry } from "./key-command-registry"; | ||
| export { ModuleRegistry } from "./module-registry"; | ||
| export { PublicApiBuilder } from "./public-api-builder"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { Contract } from "../di/contract"; | ||
| import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands"; | ||
|
|
||
| /** | ||
| * The key-command face of the injector facade (the `keyCommands.` namespace). | ||
| * Kept separate from CommandRegistry because the two registries are redesigned | ||
| * on different tracks. | ||
| */ | ||
| @Contract({ name: "keyCommandRegistry" }) | ||
| export abstract class KeyCommandRegistry { | ||
| abstract requireKeyCommand(name: IValidKeyName, file: string): void; | ||
| abstract registerKeyCommand(name: IValidKeyName, resolver: any): void; | ||
| abstract resolveKeyCommand(name: string): IKeyCommand; | ||
| abstract getRegisteredKeyCommandsNames(): string[]; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.