feat(di): token-based dependency injection behind the Yok facade (phase 1) - #6099
feat(di): token-based dependency injection behind the Yok facade (phase 1)#6099edusperoni wants to merge 14 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe pull request introduces a typed dependency-injection container, contract-based service tokens, a container-backed legacy Yok facade, configurable deprecation reporting, updated hook guidance, renamed service implementations, compatibility tests, and public contracts package entry points. ChangesTyped DI migration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Hook
participant HooksService
participant Injector
participant Service
Hook->>HooksService: execute in-process hook
HooksService->>Injector: resolve hook dependencies
Injector->>Service: get typed or legacy token
Service-->>Injector: return service
Injector-->>HooksService: invoke hook in context
HooksService-->>Hook: return hook result
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
28e7169 to
7ff482a
Compare
Purpose-built container with an Angular-compatible surface (inject, runInInjectionContext, Injector, provide/provideLazy, forwardRef). Lookup is class-object first with a fallback to the decorator-set name, per injector level, so per-call string overrides and duplicated contract copies in the extensions tree resolve to the same provider. Includes a legacy provider kind that constructs Yok-style classes via annotate(), lazy side-effect loaders for path-based registration, transient retention, and reverse-instantiation-order disposal.
reportDeprecation() dedups per api+detail and logs at trace level by default; NS_DEPRECATIONS=warn|error previews the stricter stages so the same call sites can be escalated over releases. Wired at the external entry points only: param-name hook invocation, require-time extension registration, and dynamicCall help templating.
A class migrated off property injection has neither $hooksService nor $injector, so the decorator threw at hook-execution time. The global injector must stay last in the chain - tests stub the instance properties and rely on them winning.
Yok keeps its entire public surface, subclassability, and the global $injector, while storage and resolution delegate to the new container. Command routing, key commands, and the public-API builder are unchanged. Every legacy member now carries @deprecated JSDoc naming its replacement, mirrored on IInjector.
DoctorService and ProjectNameService become @contract abstract classes; their impls are renamed *Impl (externally invisible - outside resolution is by string name or token, never class identity). The subpath resolves through contracts/package.json rather than an exports map, so existing deep requires keep working, and the entry point is side-effect-free so a duplicated CLI copy in an extensions tree never boots a second runtime.
Fixtures exercise the surfaces third parties rely on: param-name hook signatures across every payload shape and influence channel (mutation, function-return middleware, abort), require-time extension registration against global.$injector including hierarchical commands, and the full IInjector facade surface. Notably they pin that a hook naming an unwrapped payload key (the after-watchAction shape) is skipped as invalid - resolution changes must not resurrect long-dead hooks.
dependency-injection.md covers tokens (@contract), inject()/Injector, provider kinds, resolution semantics, forwardRef, coexistence with the legacy $injector, and a legacy-to-new quick reference. extending-cli.md leads with the recommended hook pattern - inject() works directly in hook bodies because they run in an injection context, pinned by a new compat test - and demotes parameter-name injection to a labeled legacy section. String-token lookups are framed as the migration bridge, not a co-equal API. The hook deprecation tracer now flags only hooks that actually use param-name service injection; a hookArgs-only signature follows the recommended pattern and stays silent.
7ff482a to
a23a901
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (4)
test/di.ts (1)
1-404: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMissing coverage for
peek(),has(), andremove().These are public
Injectormethods with no test exercising them in this file, unlike every other public method (get,register,createChild,createInstance,dispose,getRegisteredNames). Worth adding cases given this is the new core DI surface other layers build on.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/di.ts` around lines 1 - 404, Add focused tests in the DI test suite covering the public Injector methods peek(), has(), and remove(). Verify presence checks and non-throwing inspection behavior, removal of registered entries, and the resulting lookup/registration state, including the relevant child or provider scope behavior exposed by these methods.lib/common/di/injector.ts (1)
190-225: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
forwardRefisn't resolved for provider value fields, only forprovide.
resolveForwardRefis applied to theprovidetoken everywhere (keysFor,findRecordLocal,get), butapplyProviderstoresuseClass/useFactory/useLazyClassverbatim. If a caller wraps an implementation reference inforwardRef(e.g. to defer a TDZ on the impl class itself rather than the token),construct()will donew record.useClass()on the raw forwardRef thunk function and throw a confusing "is not a constructor" instead of the real class.dependency-injection.mdonly documentsforwardReffor theprovideposition, so this is a latent misuse trap rather than a currently-exercised bug.♻️ Optional: resolve forwardRef on impl fields too
if ("useValue" in provider) { record.kind = "value"; record.useValue = provider.useValue; ... } else if ("useClass" in provider) { record.kind = "class"; - record.useClass = provider.useClass; + record.useClass = resolveForwardRef(provider.useClass); } else if ("useFactory" in provider) { record.kind = "factory"; record.useFactory = provider.useFactory; } else if ("useLazyClass" in provider) { record.kind = "lazyClass"; record.useLazyClass = provider.useLazyClass; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/common/di/injector.ts` around lines 190 - 225, Update applyProvider to resolve forwardRef-wrapped implementation fields before storing them in record.useClass, record.useFactory, record.useLazyClass, or record.useLegacyClass, while preserving existing provider-kind selection and value handling. Reuse the existing resolveForwardRef helper and leave the provide-token resolution paths unchanged.extending-cli.md (1)
124-124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse descriptive link text instead of "here".
Flagged by markdownlint (MD059). Screen readers announce link text out of context, so "here" isn't descriptive.
✏️ Suggested fix
-The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions). +The type of the parameters is described in the [CLI's `.d.ts` definition files](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@extending-cli.md` at line 124, Replace the non-descriptive “here” link text in the CLI type-definition reference with text that identifies the linked definitions directory, while preserving the existing destination URL and surrounding explanation.Source: Linters/SAST tools
test/compat/legacy-extension.ts (1)
14-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRegistered globals aren't cleaned up after the tests.
Both tests register commands/services on the shared
global.$injector("extCompatService", "meowcompat|purr", "meowcompat", "conflictcompat") but never remove them inafterEach. Since this is the process-wide container shared across the whole suite, these registrations persist for the remainder of the test run.♻️ Suggested cleanup
afterEach(() => { fs.rmSync(extDir, { recursive: true, force: true }); delete (<any>global).__extCapture; + ["extCompatService", "commands.meowcompat|purr", "commands.meowcompat", "commands.conflictcompat"].forEach( + (name) => (<any>cliGlobal.$injector).di.remove(name), + ); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/compat/legacy-extension.ts` around lines 14 - 110, Update the afterEach cleanup in the legacy extension contract tests to remove all registrations added through the shared global.$injector, including extCompatService, meowcompat|purr, meowcompat, and conflictcompat, and restore any modified injector state such as overrideAlreadyRequiredModule. Keep filesystem and global capture cleanup intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@dependency-injection.md`:
- Around line 120-131: Update the Provider kinds documentation near the useValue
entry to add a one-line caveat that combining useValue with shared: false causes
get() to throw “no resolver registered for ...”, preserving the existing Yok
behavior referenced by the DI tests.
In `@lib/services/doctor-service.ts`:
- Around line 215-217: Update canExecuteLocalBuild to safely handle an omitted
configuration argument before accessing configuration.platform,
configuration.projectDir, or configuration.runtimeVersion. Default the optional
argument to an empty configuration object, or make it required consistently in
the method contract, while preserving the existing getInfos call behavior.
- Around line 31-33: Update the DoctorServiceImpl.printWarnings method to accept
an optional trackResult?: boolean, matching the DoctorService and IDoctorService
contracts while preserving existing behavior when trackResult is provided.
- Around line 181-190: Update the Darwin and Windows branches in
runSetupScript() to return the IS
pawnResult from runSetupScriptCore instead of awaiting and discarding it. Ensure
both platform-specific paths return their core result consistently while
preserving the existing script locations and arguments.
---
Nitpick comments:
In `@extending-cli.md`:
- Line 124: Replace the non-descriptive “here” link text in the CLI
type-definition reference with text that identifies the linked definitions
directory, while preserving the existing destination URL and surrounding
explanation.
In `@lib/common/di/injector.ts`:
- Around line 190-225: Update applyProvider to resolve forwardRef-wrapped
implementation fields before storing them in record.useClass, record.useFactory,
record.useLazyClass, or record.useLegacyClass, while preserving existing
provider-kind selection and value handling. Reuse the existing resolveForwardRef
helper and leave the provide-token resolution paths unchanged.
In `@test/compat/legacy-extension.ts`:
- Around line 14-110: Update the afterEach cleanup in the legacy extension
contract tests to remove all registrations added through the shared
global.$injector, including extCompatService, meowcompat|purr, meowcompat, and
conflictcompat, and restore any modified injector state such as
overrideAlreadyRequiredModule. Keep filesystem and global capture cleanup
intact.
In `@test/di.ts`:
- Around line 1-404: Add focused tests in the DI test suite covering the public
Injector methods peek(), has(), and remove(). Verify presence checks and
non-throwing inspection behavior, removal of registered entries, and the
resulting lookup/registration state, including the relevant child or provider
scope behavior exposed by these methods.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7531a01a-933a-4539-98ef-db2c1f39eda9
📒 Files selected for processing (29)
contracts/package.jsondependency-injection.mdextending-cli.mdlib/common/definitions/yok.d.tslib/common/deprecation.tslib/common/di/contract.tslib/common/di/forward-ref.tslib/common/di/index.tslib/common/di/inject.tslib/common/di/injector.tslib/common/di/providers.tslib/common/helpers.tslib/common/services/hooks-service.tslib/common/yok.tslib/contracts/doctor-service.tslib/contracts/index.tslib/contracts/project-name-service.tslib/services/doctor-service.tslib/services/extensibility-service.tslib/services/project-name-service.tsscripts/copy-assets.jstest/compat/injector-facade-surface.tstest/compat/legacy-extension.tstest/compat/legacy-hooks.tstest/contracts.tstest/deprecation.tstest/di.tstest/project-name-service.tstest/services/doctor-service.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@extending-cli.md`:
- Around line 80-99: The hook example’s import omits the Injector symbol used by
the late-lookup guidance. Update the shown nativescript/contracts import to
include Injector, and keep the existing inject(Injector) pattern consistent with
that public contracts entry point.
In `@lib/common/deprecation.ts`:
- Around line 33-57: Update reportDeprecation so the deduplication key is added
to reported only after a logger has been resolved and the message has been
delivered via logger.warn or logger.trace. Keep the early return for already
reported keys, but allow retries when no logger is available.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 81105fc2-45b1-455f-86de-700f67bdb99c
📒 Files selected for processing (29)
contracts/package.jsondependency-injection.mdextending-cli.mdlib/common/definitions/yok.d.tslib/common/deprecation.tslib/common/di/contract.tslib/common/di/forward-ref.tslib/common/di/index.tslib/common/di/inject.tslib/common/di/injector.tslib/common/di/providers.tslib/common/helpers.tslib/common/services/hooks-service.tslib/common/yok.tslib/contracts/doctor-service.tslib/contracts/index.tslib/contracts/project-name-service.tslib/services/doctor-service.tslib/services/extensibility-service.tslib/services/project-name-service.tsscripts/copy-assets.jstest/compat/injector-facade-surface.tstest/compat/legacy-extension.tstest/compat/legacy-hooks.tstest/contracts.tstest/deprecation.tstest/di.tstest/project-name-service.tstest/services/doctor-service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- lib/common/definitions/yok.d.ts
- dependency-injection.md
- test/services/doctor-service.ts
createHierarchicalCommand registered the synthesized parent - and its execute path resolved commandsService and errors - through the module-level global injector instead of the instance it was called on, so a parent dispatcher leaked onto the global injector whenever a hierarchical command was registered on any other instance. The require-ordering guard accidentally depended on that leak: with the parent now landing on the same instance, it is exempted for synthesized parents (they exist because a child registered, not because requires ran out of order), and the else branch mirrors the existing default-command skip. Net effect: register-then-require orderings that used to throw 'Default commands should be required before child commands' now work; nothing that worked before changes.
Internal code no longer reads global.$injector: the two cycle-bound sites (the deprecation tracer's logger fallback and @hook's last-resort lookup) go through a getInjector() accessor required at call time. The global property remains write-only from the CLI's side - it exists as the published surface for extensions and hooks.
The token container was reachable only through a getter on the concrete Yok class, so every crossing from facade-typed code into the new API needed an any cast - the migration's most important seam was invisible to the type system. IInjector now declares readonly di: Injector.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/common/yok.ts`:
- Around line 592-601: Update getInjector and the published global.$injector
surface so both always reference the same injector, including when callers
assign global.$injector directly; use a setter or computed alias that updates
the module-local injector rather than a stale cached value. Preserve existing
setGlobalInjector behavior and add a compatibility test covering direct
global.$injector assignment and subsequent getInjector, hook, or legacy
resolution.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 2fe293f5-439a-47c5-80db-09b2e0b95116
📒 Files selected for processing (5)
lib/common/deprecation.tslib/common/helpers.tslib/common/yok.tstest/compat/legacy-hooks.tstest/deprecation.ts
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/common/definitions/yok.d.ts (1)
159-163: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winQualify the
$injectormigration guidance by injection context.
inject(Injector)is only valid while anInjectoris active, so mention that limitation and direct late lookups to an already-obtainedInjectorviaget()instead of instructing callers to callinject(Injector)everywhere.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/common/definitions/yok.d.ts` around lines 159 - 163, Update the deprecation guidance for $injector to state that inject(Injector) is valid only within an active Injector context, and direct late lookups to reuse an already-obtained Injector through get().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/common/definitions/yok.d.ts`:
- Around line 12-18: Update the exported IInjector compatibility surface to
account for the new readonly di: Injector requirement: either make the bridge
optional or provide it through an appropriate compatibility mechanism for legacy
implementations. Preserve assignability for existing stubs and hook
implementations unless this change is intentionally versioned and documented as
breaking.
---
Outside diff comments:
In `@lib/common/definitions/yok.d.ts`:
- Around line 159-163: Update the deprecation guidance for $injector to state
that inject(Injector) is valid only within an active Injector context, and
direct late lookups to reuse an already-obtained Injector through get().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 205ebfba-4676-4b3e-8da9-304425e99b24
📒 Files selected for processing (2)
lib/common/definitions/yok.d.tstest/compat/legacy-hooks.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- test/compat/legacy-hooks.ts
Yok is now an Injector - class Yok extends Injector - so the new API works on the facade directly (get, register with Providers, createChild, runInInjectionContext($injector, ...)) and inject(Injector) inside legacy-constructed classes returns the facade itself instead of a second, inner container identity. The di bridge is gone. register() dispatches by argument shape: a string first argument is the legacy name-based form, anything else is a Provider. IInjector now extends the Injector class type, which constrains implementers to the real class hierarchy (Yok and its subclasses) - intentional, since the interface only ever described Yok.
IInjector recomposes from per-subsystem faces - CommandRegistry, KeyCommandRegistry, ModuleRegistry, PublicApiBuilder - each an @contract token the facade registers itself under. One object still implements everything until the subsystems are physically extracted; extraction then becomes a provider swap for the face's token instead of a consumer migration. Consumers can depend on the narrow face they actually use, and deprecation becomes per-face instead of a flat everything-is-legacy. The contracts are internal (lib/common/contracts) and deliberately not re-exported from nativescript/contracts; promoting one is a per-contract decision.
- doctor-service: printWarnings accepts an optional trackResult matching its contracts; runSetupScript returns the setup script result instead of resolving undefined; canExecuteLocalBuild no longer dereferences its optional argument - deprecation tracer: a report dropped for lack of a logger is no longer latched as delivered - it reports once a logger exists - yok: global.$injector is an accessor pair, so a direct third-party assignment stays synchronized with the binding getInjector() reads - docs: note the useValue + shared:false quirk; show where Injector is imported from in the late-lookup example
optional resolves to null instead of throwing for unknown tokens (a found-but-misconfigured provider still throws); skipSelf starts at the parent, escaping a child scope's shadowing entry; self refuses parent fallthrough. self+skipSelf throws. host is deliberately absent - it is an Angular component-tree concept with no analog in this hierarchy. get()'s former second parameter - the legacy per-call ctorArguments bag - had exactly one caller, the facade's own resolve(name, bag). It moves to a protected getWithLegacyArguments channel, so the public get() aligns with Angular's shape today rather than after the legacy paths are deleted.
PR Checklist
What is the current behavior?
Dependency injection is name-based:
annotate()discovers a constructor's dependencies by regex-parsing its source text (fn.toString()), keyed on$-prefixed parameter names. That is why the test suite must run againsttsc's emit indist/instead of the TypeScript sources (see the comment invitest.config.ts), why the CLI can never be bundled or minified, and why any emit change that rewrites constructor parameters breaks every registration at runtime with nothing caught at type-check time. The injector (Yok) also fuses four concerns — DI container, lazy module loader, command registry/router, and the public-API builder — and nothing about resolution is typed.What is the new behavior?
Phase 1 of the DI modernization: a purpose-built, token-based container now backs the injector, while every existing surface keeps working unchanged.
lib/common/di/): Angular-compatible API —inject(),runInInjectionContext(),Injector,provide/provideLazy,forwardRef. Tokens are abstract classes annotated with@Contract({ name }); lookup is class-object first with a per-level fallback to the token name, so string spellings (doctorService,$doctorService), per-call overrides, and duplicated contract copies in the extensions tree all resolve to the same provider. Lazy path-based loading, transient retention, and disposal (reverse instantiation order) are preserved as native provider kinds; a legacy provider kind still constructs unmigrated classes viaannotate().global.$injectorare intact — storage and resolution moved to the new container. The existing yok unit tests (~67 cases) and the public-API surface test pass unchanged.nativescript/contractssubpath (via a directorypackage.json, deliberately noexportsmap so deep requires keep working) ships the first contract tranche (DoctorService,ProjectNameService); implementations are renamed*Impl, which is externally invisible since outside resolution is by name or token, never class identity.NS_DEPRECATIONS=warn|errorpreviews) — and the whole legacy surface (Yokmembers,IInjector,annotate(),@hook) carries@deprecatedJSDoc naming its replacement.global.$injector, and the fullIInjectorfacade surface — so future migration steps are gated on third-party behavior, not just internal tests.@hook()now reaches the global injector as a last resort, so classes migrated off property injection keep their hooks; instance-level$hooksService/$injectorstill win (test seams preserved).No behavior change intended: full suite green (1578 tests), no cold-start regression (
ns --version~0.92s vs 0.95s baseline).Documentation
dependency-injection.md— the new API end to end:@Contracttokens,inject()/runInInjectionContext/Injector, provider kinds (provide,provideLazy, values, factories,shared: false),createInstancewith per-call overrides, resolution semantics (class-first/name-fallback,$normalization, child scopes),forwardRef, coexistence with the legacy$injector(one shared container,NS_DEPRECATIONSstaging), guidance for extension/hook authors, the shipped contract tranche, and a legacy → new quick reference.extending-cli.md— the in-process hooks guide now leads with the typed API: hook bodies run in an injection context (pinned by a compat test), so the recommended pattern isinject(Token)directly in the hook, withhookArgsdeclared only when the payload is needed; parameter-name injection is demoted to a clearly labeled legacy section. The deprecation tracer matches the guidance: hooks declaring onlyhookArgsare not flagged — only actual parameter-name service injection is.API at a glance
Summary by CodeRabbit