Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------

Expand Down
55 changes: 45 additions & 10 deletions lib/common/di/inject.ts
Original file line number Diff line number Diff line change
@@ -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 (<any>globalThis)[CONTEXT_SLOT] || null;
}

export function inject<T = any>(token: ProviderToken<T>): T;
export function inject<T = any>(
Expand All @@ -20,23 +39,39 @@ export function inject<T = any>(
token: ProviderToken<T>,
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 " +
"runInInjectionContext(). It is not valid after an await; 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<T>(injector: Injector, fn: () => T): T {
const previous = current;
current = injector;
const g = <any>globalThis;
const previous = g[CONTEXT_SLOT];
g[CONTEXT_SLOT] = <IInjectionContextFrame>{ injector, owner: COPY_ID };
try {
return fn();
} finally {
current = previous;
g[CONTEXT_SLOT] = previous;
}
}
46 changes: 46 additions & 0 deletions test/di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]);
Expand Down