Skip to content

Commit 7fe0f09

Browse files
committed
feat(di): add a token-based injector with @contract tokens
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.
1 parent 745f3a8 commit 7fe0f09

7 files changed

Lines changed: 965 additions & 0 deletions

File tree

lib/common/di/contract.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* The name is stored under a `Symbol.for` key deliberately: extensions install
3+
* into their own node_modules tree, so a duplicated copy of this module (and
4+
* of any contract class) must write and read the same property key. A unique
5+
* `Symbol()` would make duplicate copies mutually invisible and break the
6+
* name-fallback lookup in `Injector.get()`.
7+
*/
8+
export const CONTRACT_NAME = Symbol.for("nativescript:di:contractName");
9+
10+
export interface IContractOptions {
11+
/**
12+
* Canonical token name, without the `$` prefix. Must be an explicit string
13+
* literal — never derive it from `class.name`, which changes under
14+
* minification.
15+
*/
16+
name: string;
17+
}
18+
19+
// Per module instance on purpose: a duplicated CLI copy in an extensions tree
20+
// carries its own registry, so contracts redeclared by another copy never
21+
// false-positive here.
22+
const mintedNames = new Map<string, Function>();
23+
24+
/**
25+
* Marks an abstract class as a DI token. The decorated class resolves by
26+
* object identity first and by its name on a miss, so duplicated copies of a
27+
* contract remain interchangeable across node_modules trees.
28+
*/
29+
export function Contract(
30+
options: IContractOptions,
31+
): (target: Function) => void {
32+
const { name } = options;
33+
return (target: Function): void => {
34+
const existing = mintedNames.get(name);
35+
if (existing && existing !== target) {
36+
throw new Error(
37+
`@Contract name '${name}' is already used by '${
38+
existing.name || "another contract"
39+
}'. Token names must be unique — a duplicate silently aliases two contracts.`,
40+
);
41+
}
42+
mintedNames.set(name, target);
43+
Object.defineProperty(target, CONTRACT_NAME, {
44+
value: name,
45+
writable: false,
46+
enumerable: false,
47+
configurable: false,
48+
});
49+
};
50+
}
51+
52+
/**
53+
* Reads the decorator-set name. Own-property check only: an implementation
54+
* class extending a contract inherits the property, but must not itself act
55+
* as a token.
56+
*/
57+
export function getContractName(token: any): string | undefined {
58+
if (
59+
typeof token === "function" &&
60+
Object.prototype.hasOwnProperty.call(token, CONTRACT_NAME)
61+
) {
62+
return (<any>token)[CONTRACT_NAME];
63+
}
64+
return undefined;
65+
}
66+
67+
/** Test seam — the duplicate-name registry otherwise persists per process. */
68+
export function clearMintedContractNames(): void {
69+
mintedNames.clear();
70+
}

lib/common/di/forward-ref.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// `Symbol.for` so a duplicated CLI copy in an extensions tree marks thunks
2+
// with the same key this copy reads — mirrors the CONTRACT_NAME reasoning.
3+
const FORWARD_REF = Symbol.for("nativescript:di:forwardRef");
4+
5+
/**
6+
* Defers a token reference until the container reads it — for provider arrays
7+
* evaluated at module load, where a class declared later in the file (TDZ) or
8+
* reached through a circular import is not yet a usable binding. Same
9+
* semantics as Angular's forwardRef; resolved at registration and lookup.
10+
*
11+
* This defers *references*, not construction: it cannot break an
12+
* instantiation cycle between two services. For that, inject the Injector and
13+
* resolve late.
14+
*/
15+
export function forwardRef<T>(fn: () => T): T {
16+
(<any>fn)[FORWARD_REF] = true;
17+
return <any>fn;
18+
}
19+
20+
export function resolveForwardRef<T>(token: T): T {
21+
if (typeof token === "function" && (<any>token)[FORWARD_REF] === true) {
22+
return (<any>token)();
23+
}
24+
return token;
25+
}

lib/common/di/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export { Injector } from "./injector";
2+
export { inject, runInInjectionContext } from "./inject";
3+
export { forwardRef, resolveForwardRef } from "./forward-ref";
4+
export {
5+
Contract,
6+
getContractName,
7+
CONTRACT_NAME,
8+
clearMintedContractNames,
9+
} from "./contract";
10+
export type { IContractOptions } from "./contract";
11+
export { provide, provideLazy } from "./providers";
12+
export type {
13+
Provider,
14+
ProviderToken,
15+
Type,
16+
AbstractType,
17+
IClassProvider,
18+
IValueProvider,
19+
IFactoryProvider,
20+
ILazyClassProvider,
21+
ILegacyClassProvider,
22+
ILazyRequireProvider,
23+
} from "./providers";

lib/common/di/inject.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Injector } from "./injector";
2+
import type { ProviderToken } from "./providers";
3+
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;
9+
10+
export function inject<T = any>(token: ProviderToken<T>): T {
11+
if (!current) {
12+
throw new Error(
13+
"inject() can only be called from an injection context — a field " +
14+
"initializer, a constructor, or a provider factory running under " +
15+
"runInInjectionContext(). It is not valid after an await; inject " +
16+
"the Injector itself and use injector.get() for late lookups.",
17+
);
18+
}
19+
return current.get(token);
20+
}
21+
22+
export function runInInjectionContext<T>(injector: Injector, fn: () => T): T {
23+
const previous = current;
24+
current = injector;
25+
try {
26+
return fn();
27+
} finally {
28+
current = previous;
29+
}
30+
}

0 commit comments

Comments
 (0)