From 4ced2bfc9cd07549aa1092595e454edff56a9be4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 5 Aug 2026 13:29:42 -0300 Subject: [PATCH] fix(di): share the injection context across duplicated CLI copies The context slot was module-local, so a hook or extension module that resolves a different copy of the CLI than the one running (a nested nativescript install, or a project-local copy under a globally-run CLI) got a dead slot and inject() threw despite being synchronously inside a valid context. The slot now lives on globalThis under a Symbol.for key, and a copy serving inject() through a frame it did not set warns once, naming its path - the duplicated copy works but loads the CLI twice, and peerDependencies avoid it. The second-copy test loads a genuinely separate module instance: inject.js has no runtime imports, so a copied file is the real duplicated-copy situation. --- dependency-injection.md | 7 ++++++ lib/common/di/inject.ts | 55 +++++++++++++++++++++++++++++++++-------- test/di.ts | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/dependency-injection.md b/dependency-injection.md index e645ebdf56..b8386c1f9b 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -110,6 +110,13 @@ 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. +The injection context is shared process-wide. If a hook or extension module +ends up resolving a *duplicated* copy of the CLI (a nested `nativescript` +install, or a project-local copy under a globally-run CLI), its `inject()` +still resolves against the running CLI's context — with a one-time warning, +because a duplicated copy loads the CLI twice. Declaring `nativescript` as a +`peerDependency` lets the running copy be shared instead. + Registering: providers ---------------------- diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts index aba3377b04..c29be1bed2 100644 --- a/lib/common/di/inject.ts +++ b/lib/common/di/inject.ts @@ -1,11 +1,30 @@ import type { Injector, InjectOptions } from "./injector"; import type { ProviderToken } from "./providers"; -// Sync-only by design (no AsyncLocalStorage): `current` is restored in a -// finally, so inject() is valid in field initializers, constructor bodies and -// provider factories — and never after an await. Self-inject the Injector for -// later lookups. -let current: Injector | null = null; +/** + * The injection context lives on globalThis under a `Symbol.for` key rather + * than in a module-local variable: a hook or extension module can resolve a + * DIFFERENT copy of this file than the one the running CLI set the context + * through (a nested nativescript install, or a project-local copy under a + * globally-run CLI), and a module-local slot would make that copy's inject() + * throw despite being synchronously inside a valid context. + */ +const CONTEXT_SLOT = Symbol.for("nativescript:di:injectionContext"); + +interface IInjectionContextFrame { + injector: Injector; + /** Identifies which loaded copy of this module set the frame. */ + owner: object; +} + +// One per loaded copy of this module — the cross-copy detection marker. +const COPY_ID = {}; + +let reportedCrossCopyUse = false; + +function currentFrame(): IInjectionContextFrame | null { + return (globalThis)[CONTEXT_SLOT] || null; +} export function inject(token: ProviderToken): T; export function inject( @@ -20,7 +39,8 @@ export function inject( token: ProviderToken, options?: InjectOptions, ): T | null { - if (!current) { + const frame = currentFrame(); + if (!frame) { throw new Error( "inject() can only be called from an injection context — a field " + "initializer, a constructor, or a provider factory running under " + @@ -28,15 +48,30 @@ export function inject( "the Injector itself and use injector.get() for late lookups.", ); } - return current.get(token, options); + + if (frame.owner !== COPY_ID && !reportedCrossCopyUse) { + reportedCrossCopyUse = true; + const logger = frame.injector.get("logger", { optional: true }); + if (logger) { + logger.warn( + `A second copy of the NativeScript CLI (${__dirname}) is serving ` + + `inject() in this process. This works, but loads the CLI twice; ` + + `extensions and projects should declare nativescript as a ` + + `peerDependency so the running copy is shared.`, + ); + } + } + + return frame.injector.get(token, options); } export function runInInjectionContext(injector: Injector, fn: () => T): T { - const previous = current; - current = injector; + const g = globalThis; + const previous = g[CONTEXT_SLOT]; + g[CONTEXT_SLOT] = { injector, owner: COPY_ID }; try { return fn(); } finally { - current = previous; + g[CONTEXT_SLOT] = previous; } } diff --git a/test/di.ts b/test/di.ts index 5739a52b7c..e36da636ae 100644 --- a/test/di.ts +++ b/test/di.ts @@ -187,6 +187,52 @@ describe("di: forwardRef", () => { }); }); +describe("di: cross-copy injection context", () => { + // inject.js has no runtime imports, so a copied file loaded from another + // path is a genuine second instance of the module — the same situation as + // a nested nativescript install serving a hook or extension module. + const loadSecondCopy = (): any => { + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const source = require.resolve("../lib/common/di/inject.js"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-di-copy-")); + const target = path.join(dir, "inject.js"); + fs.copyFileSync(source, target); + return require(target); + }; + + it("a second copy's inject() resolves against the running copy's context, with a one-time warning", () => { + const copyB = loadSecondCopy(); + const warnings: string[] = []; + const loggerValue = { + warn: (message: string) => warnings.push(message), + }; + const injector = new Injector([ + { provide: "logger", useValue: loggerValue }, + ]); + + runInInjectionContext(injector, () => { + // The running copy serving its own context never warns. + assert.strictEqual(inject("logger"), loggerValue); + assert.equal(warnings.length, 0); + + // The second copy resolves through the shared slot — and warns once. + assert.strictEqual(copyB.inject("logger"), loggerValue); + assert.strictEqual(copyB.inject("logger"), loggerValue); + }); + + assert.equal(warnings.length, 1); + assert.include(warnings[0], "second copy of the NativeScript CLI"); + assert.include(warnings[0], "peerDependency"); + }); + + it("a second copy outside any context still throws the teaching error", () => { + const copyB = loadSecondCopy(); + assert.throws(() => copyB.inject("logger"), /injection context/); + }); +}); + describe("di: inject options", () => { it("optional resolves to null for an unknown token, and normally for a known one", () => { const injector = new Injector([provide(Greeter, GreeterImpl)]);