Skip to content

Commit b52b8e0

Browse files
committed
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.
1 parent ccb1ad4 commit b52b8e0

3 files changed

Lines changed: 98 additions & 10 deletions

File tree

dependency-injection.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ throws. `self` and `skipSelf` cannot be combined. There is deliberately no
110110
`host` option: it is an Angular component-tree concept with no analog in the
111111
CLI's injector hierarchy.
112112

113+
The injection context is shared process-wide. If a hook or extension module
114+
ends up resolving a *duplicated* copy of the CLI (a nested `nativescript`
115+
install, or a project-local copy under a globally-run CLI), its `inject()`
116+
still resolves against the running CLI's context — with a one-time warning,
117+
because a duplicated copy loads the CLI twice. Declaring `nativescript` as a
118+
`peerDependency` lets the running copy be shared instead.
119+
113120
Registering: providers
114121
----------------------
115122

lib/common/di/inject.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import type { Injector, InjectOptions } from "./injector";
22
import type { ProviderToken } from "./providers";
33

4-
// Sync-only by design (no AsyncLocalStorage): `current` is restored in a
5-
// finally, so inject() is valid in field initializers, constructor bodies and
6-
// provider factories — and never after an await. Self-inject the Injector for
7-
// later lookups.
8-
let current: Injector | null = null;
4+
/**
5+
* The injection context lives on globalThis under a `Symbol.for` key rather
6+
* than in a module-local variable: a hook or extension module can resolve a
7+
* DIFFERENT copy of this file than the one the running CLI set the context
8+
* through (a nested nativescript install, or a project-local copy under a
9+
* globally-run CLI), and a module-local slot would make that copy's inject()
10+
* throw despite being synchronously inside a valid context.
11+
*/
12+
const CONTEXT_SLOT = Symbol.for("nativescript:di:injectionContext");
13+
14+
interface IInjectionContextFrame {
15+
injector: Injector;
16+
/** Identifies which loaded copy of this module set the frame. */
17+
owner: object;
18+
}
19+
20+
// One per loaded copy of this module — the cross-copy detection marker.
21+
const COPY_ID = {};
22+
23+
let reportedCrossCopyUse = false;
24+
25+
function currentFrame(): IInjectionContextFrame | null {
26+
return (<any>globalThis)[CONTEXT_SLOT] || null;
27+
}
928

1029
export function inject<T = any>(token: ProviderToken<T>): T;
1130
export function inject<T = any>(
@@ -20,23 +39,39 @@ export function inject<T = any>(
2039
token: ProviderToken<T>,
2140
options?: InjectOptions,
2241
): T | null {
23-
if (!current) {
42+
const frame = currentFrame();
43+
if (!frame) {
2444
throw new Error(
2545
"inject() can only be called from an injection context — a field " +
2646
"initializer, a constructor, or a provider factory running under " +
2747
"runInInjectionContext(). It is not valid after an await; inject " +
2848
"the Injector itself and use injector.get() for late lookups.",
2949
);
3050
}
31-
return current.get(token, options);
51+
52+
if (frame.owner !== COPY_ID && !reportedCrossCopyUse) {
53+
reportedCrossCopyUse = true;
54+
const logger = frame.injector.get("logger", { optional: true });
55+
if (logger) {
56+
logger.warn(
57+
`A second copy of the NativeScript CLI (${__dirname}) is serving ` +
58+
`inject() in this process. This works, but loads the CLI twice; ` +
59+
`extensions and projects should declare nativescript as a ` +
60+
`peerDependency so the running copy is shared.`,
61+
);
62+
}
63+
}
64+
65+
return frame.injector.get(token, options);
3266
}
3367

3468
export function runInInjectionContext<T>(injector: Injector, fn: () => T): T {
35-
const previous = current;
36-
current = injector;
69+
const g = <any>globalThis;
70+
const previous = g[CONTEXT_SLOT];
71+
g[CONTEXT_SLOT] = <IInjectionContextFrame>{ injector, owner: COPY_ID };
3772
try {
3873
return fn();
3974
} finally {
40-
current = previous;
75+
g[CONTEXT_SLOT] = previous;
4176
}
4277
}

test/di.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,52 @@ describe("di: forwardRef", () => {
187187
});
188188
});
189189

190+
describe("di: cross-copy injection context", () => {
191+
// inject.js has no runtime imports, so a copied file loaded from another
192+
// path is a genuine second instance of the module — the same situation as
193+
// a nested nativescript install serving a hook or extension module.
194+
const loadSecondCopy = (): any => {
195+
const fs = require("fs");
196+
const os = require("os");
197+
const path = require("path");
198+
const source = require.resolve("../lib/common/di/inject.js");
199+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-di-copy-"));
200+
const target = path.join(dir, "inject.js");
201+
fs.copyFileSync(source, target);
202+
return require(target);
203+
};
204+
205+
it("a second copy's inject() resolves against the running copy's context, with a one-time warning", () => {
206+
const copyB = loadSecondCopy();
207+
const warnings: string[] = [];
208+
const loggerValue = {
209+
warn: (message: string) => warnings.push(message),
210+
};
211+
const injector = new Injector([
212+
{ provide: "logger", useValue: loggerValue },
213+
]);
214+
215+
runInInjectionContext(injector, () => {
216+
// The running copy serving its own context never warns.
217+
assert.strictEqual(inject("logger"), loggerValue);
218+
assert.equal(warnings.length, 0);
219+
220+
// The second copy resolves through the shared slot — and warns once.
221+
assert.strictEqual(copyB.inject("logger"), loggerValue);
222+
assert.strictEqual(copyB.inject("logger"), loggerValue);
223+
});
224+
225+
assert.equal(warnings.length, 1);
226+
assert.include(warnings[0], "second copy of the NativeScript CLI");
227+
assert.include(warnings[0], "peerDependency");
228+
});
229+
230+
it("a second copy outside any context still throws the teaching error", () => {
231+
const copyB = loadSecondCopy();
232+
assert.throws(() => copyB.inject("logger"), /injection context/);
233+
});
234+
});
235+
190236
describe("di: inject options", () => {
191237
it("optional resolves to null for an unknown token, and normally for a known one", () => {
192238
const injector = new Injector([provide(Greeter, GreeterImpl)]);

0 commit comments

Comments
 (0)