Skip to content

Commit c0f8226

Browse files
committed
fix(commands): make ctx.injector the invocation's own injector
The context handed to a handler named the injector the command was registered against, while inject() inside the same handler resolved against the per-invocation child that provides COMMAND_CONTEXT. The two lookups the docs describe as one - inject() before the first await, ctx.injector.get() after it - therefore hit different injectors, and ctx.injector.get(COMMAND_CONTEXT) threw. The context now carries the invocation's child injector. Per-command providers move from a registration-time scope into that same child, so a factory or class provider can inject the invocation, as the docs already promised; the cost is one provider instance per invocation.
1 parent 71c4324 commit c0f8226

3 files changed

Lines changed: 126 additions & 39 deletions

File tree

‎defining-commands.md‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,8 @@ The run context
344344
`const { args, arguments } = ctx` would not even parse.
345345
- `ctx.options` — the current value of each declared option, read at the moment
346346
the command executes.
347-
- `ctx.injector` — the injector this command was registered against; see
347+
- `ctx.injector` — this invocation's injector, a child of the one the command
348+
was registered against; see
348349
[Injection, and the first `await`](#injection-and-the-first-await).
349350
- `ctx.fail(message)` — fails the command with `message` and a usage help
350351
suggestion.
@@ -414,10 +415,13 @@ async run(ctx) {
414415
`ctx.injector` is deliberately the injector itself rather than a bound
415416
`ctx.inject(...)`: it is a visibly different mechanism because it obeys
416417
different rules, and mistaking one for the other is exactly the bug this shape
417-
prevents. It is the injector the command was **registered against**, so it also
418-
resolves providers a child scope supplied — see
419-
[Registering a definition](#registering-a-definition). The same guidance, and
420-
the reasoning behind it, is in `dependency-injection.md`.
418+
prevents. It is the **invocation's own injector**: a child of the one the
419+
command was registered against, holding the context under `COMMAND_CONTEXT`
420+
and any per-command providers — see
421+
[Registering a definition](#registering-a-definition). `inject()` before the
422+
first `await` and `ctx.injector.get()` after it are therefore the same lookup
423+
against the same injector. The same guidance, and the reasoning behind it, is
424+
in `dependency-injection.md`.
421425

422426
Where a handler gets its services
423427
---------------------------------
@@ -597,8 +601,10 @@ schema does not declare is a compile error. `this.context` also carries
597601
**Per-command providers see the invocation.** The context is provided to the
598602
invocation's own child injector under the `COMMAND_CONTEXT` token, which is how
599603
the base class reads it. A provider registered for one command — through the
600-
`providers` argument of `registerCommand` or `registerLazyCommand` — can inject
601-
it too, and resolves nothing outside a running invocation.
604+
`providers` argument of `registerCommand` or `registerLazyCommand` — lives in
605+
that same child, so a factory or class among them can inject the context too.
606+
The cost is that such a provider is built once per invocation, never shared
607+
across invocations, and resolves nothing outside a running one.
602608

603609
**One field per dependency.** Each service the class uses is its own field,
604610
read as `this.$x`:
@@ -678,8 +684,8 @@ the registry. It claims every name the definition declares, through the
678684
`DeferredCommandResult` — see _The owner is ambient_ below. The command instance
679685
is built by a factory on first resolution and cached.
680686

681-
Pass providers as the second argument to scope the command to a child injector
682-
of the one it registers against — how a definition is parameterized per
687+
Pass providers as the second argument to add them to each invocation's child
688+
injector, the one `ctx.injector` names — how a definition is parameterized per
683689
registration:
684690

685691
```ts

‎lib/common/services/command-definition-adapter.ts‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export function createCommandFromDefinition<
197197
>(
198198
definition: CommandDefinition<TSchema, TResult, TSetup>,
199199
targetInjector: Injector = <Injector>(<any>getRootInjector()),
200+
providers: Provider[] = [],
200201
): ICommand {
201202
const schema = definition.options || <TSchema>{};
202203
const optionNames = Object.keys(schema);
@@ -269,7 +270,10 @@ export function createCommandFromDefinition<
269270
args,
270271
params: mapArguments(args),
271272
options,
272-
injector: targetInjector,
273+
// The invocation's child injector provides this very object under
274+
// COMMAND_CONTEXT, so it can only be created - and assigned here -
275+
// once the context exists.
276+
injector: <Injector>undefined,
273277
fail,
274278
};
275279
};
@@ -375,12 +379,19 @@ export function createCommandFromDefinition<
375379
// opens one only when the current invocation has already run.
376380
let currentInvocation: Invocation = null;
377381

378-
const beginInvocation = (context: CommandContext<TSchema>): Invocation => {
382+
const beginInvocation = (args: string[]): Invocation => {
383+
const context = buildContext(args);
384+
// Per-command providers live here rather than in a registration-time
385+
// scope so that a factory or class among them can inject the
386+
// invocation; the price is one instance per invocation.
387+
const injector = targetInjector.createChild([
388+
{ provide: COMMAND_CONTEXT, useValue: context },
389+
...providers,
390+
]);
391+
context.injector = injector;
379392
const invocation: Invocation = {
380393
context,
381-
injector: targetInjector.createChild([
382-
{ provide: COMMAND_CONTEXT, useValue: context },
383-
]),
394+
injector,
384395
setup: undefined,
385396
hasRun: false,
386397
};
@@ -447,8 +458,7 @@ export function createCommandFromDefinition<
447458
? {}
448459
: {
449460
postCommandAction: async (args: string[]): Promise<void> => {
450-
const invocation =
451-
currentInvocation || beginInvocation(buildContext(args));
461+
const invocation = currentInvocation || beginInvocation(args);
452462
const context = invocation.context;
453463
const setupResult = await invocation.setup;
454464
await runInInjectionContext(invocation.injector, () =>
@@ -462,13 +472,13 @@ export function createCommandFromDefinition<
462472
},
463473
}),
464474
canExecute: async (args: string[]): Promise<boolean> => {
465-
const context = buildContext(args);
466475
// Setup first: it stands in for the constructor work legacy commands
467476
// did at resolution time, which ran before anything looked at the
468477
// arguments - so an argument validator can rely on it, and a command
469478
// run in the wrong place still reports that before complaining about
470479
// arity.
471-
const invocation = beginInvocation(context);
480+
const invocation = beginInvocation(args);
481+
const context = invocation.context;
472482
const setupResult = await invocation.setup;
473483

474484
await enforceArguments(context);
@@ -488,7 +498,7 @@ export function createCommandFromDefinition<
488498
const invocation =
489499
currentInvocation && !currentInvocation.hasRun
490500
? currentInvocation
491-
: beginInvocation(buildContext(args));
501+
: beginInvocation(args);
492502
const context = invocation.context;
493503
invocation.hasRun = true;
494504

@@ -518,14 +528,15 @@ export function registerDefinitionAs<
518528
name: string,
519529
definition: DefinedCommand<TSchema, TResult, TSetup>,
520530
targetInjector: Injector = <Injector>(<any>getRootInjector()),
531+
providers: Provider[] = [],
521532
): void {
522533
// The registry facet rather than the injector itself, so a child injector
523534
// that provides its own CommandRegistry receives the registration.
524535
const registry = targetInjector.get(CommandRegistry);
525536
// A prototype-less zero-parameter function registers as a useFactory
526537
// provider, so the command is built on first resolution and cached.
527538
registry.registerCommand(name, () =>
528-
createCommandFromDefinition(definition, targetInjector),
539+
createCommandFromDefinition(definition, targetInjector, providers),
529540
);
530541
}
531542

@@ -574,8 +585,10 @@ export async function canExecuteCommand(
574585
* behalf.
575586
*
576587
* Registration targets the injector of the current injection context, and
577-
* `providers` scope the command to a child of it. To register against some
578-
* other injector, run the call in its context:
588+
* `providers` are added to each invocation's own child of it, next to
589+
* COMMAND_CONTEXT, so they can inject the invocation and are built once per
590+
* invocation. To register against some other injector, run the call in its
591+
* context:
579592
* `runInInjectionContext(injector, () => registerCommand(definition))`.
580593
*
581594
* Every registration has an owner and claims its names, the way
@@ -589,7 +602,7 @@ export function registerCommand<
589602
TSetup = any,
590603
>(
591604
definition:
592-
| CommandClass<CommandName, TSchema, TResult>
605+
| CommandClass<CommandName, TSchema>
593606
| DefinedCommand<TSchema, TResult, TSetup>
594607
| CommandDefinition<TSchema, TResult, TSetup>,
595608
providers: Provider[] = [],
@@ -598,14 +611,13 @@ export function registerCommand<
598611
toCommandDefinition(definition) ||
599612
defineCommand(<CommandDefinition<TSchema, TResult, TSetup>>definition);
600613
const target = contextInjector();
601-
const scope = providers.length ? target.createChild(providers) : target;
602614
const owner = target.get(COMMAND_OWNER, { optional: true }) || CLI_OWNER;
603615
const registry = target.get(CommandRegistry);
604616

605617
for (const name of namesOf(defined)) {
606618
const result = registry.registerDeferredCommand(name, {
607619
owner,
608-
load: () => registerDefinitionAs(name, defined, scope),
620+
load: () => registerDefinitionAs(name, defined, target, providers),
609621
});
610622

611623
if (!result.registered) {
@@ -639,8 +651,9 @@ type MissingTypeArgument =
639651
* () => require("./commands/run").iosRunCommand,
640652
* );
641653
*
642-
* `providers` scope the command to a child injector, built when the command is
643-
* constructed rather than when its name is claimed.
654+
* `providers` are added to each invocation's own child injector, as for
655+
* `registerCommand`, so nothing about them is resolved when the name is
656+
* claimed.
644657
*
645658
* Registration targets the injector of the current injection context, if there
646659
* is one, and takes its owner from that injector's COMMAND_OWNER — which is
@@ -712,11 +725,7 @@ export function registerLazyCommand<
712725
);
713726
}
714727

715-
registerDefinitionAs(
716-
commandName,
717-
definition,
718-
providers.length ? target.createChild(providers) : target,
719-
);
728+
registerDefinitionAs(commandName, definition, target, providers);
720729
},
721730
});
722731
}

‎test/define-command.ts‎

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ describe("defineCommand", () => {
405405
);
406406

407407
await testInjector.resolveCommand("dctestambient").execute([]);
408-
assert.strictEqual(seenInjector, scope);
408+
assert.strictEqual(seenInjector.parent, scope);
409409

410410
assert.deepStrictEqual(
411411
runInInjectionContext(testInjector, () =>
@@ -2284,7 +2284,7 @@ describe("defineCommand", () => {
22842284
);
22852285
});
22862286

2287-
it("scopes the command to a child injector built on first resolution", async () => {
2287+
it("adds the providers to each invocation's injector, not at resolution", async () => {
22882288
const testInjector = createTestInjector();
22892289
const GREETING = new InjectionToken<string>("dctestLazyGreeting");
22902290
let seen: string;
@@ -2314,11 +2314,12 @@ describe("defineCommand", () => {
23142314
assert.strictEqual(children, 0);
23152315

23162316
const command = testInjector.resolveCommand("dctestlazyscoped");
2317-
assert.strictEqual(children, 1);
2317+
assert.strictEqual(children, 0);
23182318

23192319
await command.execute([]);
2320+
assert.strictEqual(children, 1);
23202321
assert.strictEqual(seen, "hello");
2321-
// The provider lives in the command's own scope, not the injector the
2322+
// The provider lives in the invocation's own injector, not the one the
23222323
// registration was made against.
23232324
assert.isNotOk((<any>testInjector).get(GREETING, { optional: true }));
23242325
});
@@ -2362,7 +2363,7 @@ describe("defineCommand", () => {
23622363
assert.deepStrictEqual(result, { registered: true });
23632364

23642365
await testInjector.resolveCommand("dctestlazyambient").execute([]);
2365-
assert.strictEqual(seenInjector, scope);
2366+
assert.strictEqual(seenInjector.parent, scope);
23662367

23672368
assert.deepStrictEqual(
23682369
runInInjectionContext(testInjector, () =>
@@ -2430,23 +2431,50 @@ describe("defineCommand", () => {
24302431
});
24312432

24322433
describe("ctx.injector", () => {
2433-
it("is the injector the command was registered against", async () => {
2434+
it("is the invocation's injector, a child of the registration injector", async () => {
24342435
const testInjector = createTestInjector();
2436+
testInjector.register("dcTestRegistered", { value: "parent" });
24352437
let seen: any;
2438+
let seenContext: any;
2439+
let injectedContext: any;
24362440

24372441
const command = createCommandFromDefinition(
24382442
defineCommand({
24392443
name: "dctest-injector",
24402444
run: (ctx) => {
2445+
injectedContext = inject(COMMAND_CONTEXT);
24412446
seen = ctx.injector;
2447+
seenContext = ctx.injector.get(COMMAND_CONTEXT);
24422448
},
24432449
}),
24442450
testInjector,
24452451
);
24462452

24472453
await command.execute([]);
24482454

2449-
assert.strictEqual(seen, testInjector);
2455+
assert.notStrictEqual(seen, testInjector);
2456+
assert.strictEqual(seenContext, injectedContext);
2457+
assert.strictEqual(seen.get("dcTestRegistered").value, "parent");
2458+
});
2459+
2460+
it("is a different injector for each invocation", async () => {
2461+
const testInjector = createTestInjector();
2462+
const seen: any[] = [];
2463+
2464+
const command = createCommandFromDefinition(
2465+
defineCommand({
2466+
name: "dctest-injector-per-invocation",
2467+
run: (ctx) => {
2468+
seen.push(ctx.injector);
2469+
},
2470+
}),
2471+
testInjector,
2472+
);
2473+
2474+
await command.execute([]);
2475+
await command.execute([]);
2476+
2477+
assert.notStrictEqual(seen[0], seen[1]);
24502478
});
24512479

24522480
it("resolves after the first await, where inject() no longer can", async () => {
@@ -2858,5 +2886,49 @@ describe("defineCommand", () => {
28582886

28592887
assert.deepEqual(ran, ["android:true:android", "ios:true:ios"]);
28602888
});
2889+
2890+
it("builds a factory provider per invocation, with the invocation in reach", async () => {
2891+
const LABEL = new InjectionToken<{ label: string }>("dcTestLabel");
2892+
const testInjector = createTestInjector();
2893+
const built: string[] = [];
2894+
const seen: any[] = [];
2895+
2896+
runInInjectionContext(testInjector, () =>
2897+
registerCommand(
2898+
defineCommand({
2899+
name: "dctest-provider-sees-invocation",
2900+
arguments: "any",
2901+
run: (ctx) => {
2902+
const first = ctx.injector.get(LABEL);
2903+
seen.push(first, ctx.injector.get(LABEL));
2904+
},
2905+
}),
2906+
[
2907+
{
2908+
provide: LABEL,
2909+
useFactory: () => {
2910+
const label = inject(COMMAND_CONTEXT).args.join("+");
2911+
built.push(label);
2912+
return { label };
2913+
},
2914+
},
2915+
],
2916+
),
2917+
);
2918+
2919+
const command = testInjector.resolveCommand(
2920+
"dctest-provider-sees-invocation",
2921+
);
2922+
await command.execute(["a", "b"]);
2923+
await command.execute(["c"]);
2924+
2925+
assert.deepEqual(built, ["a+b", "c"]);
2926+
assert.strictEqual(seen[0], seen[1]);
2927+
assert.notStrictEqual(seen[0], seen[2]);
2928+
assert.deepEqual(
2929+
seen.map((entry) => entry.label),
2930+
["a+b", "a+b", "c", "c"],
2931+
);
2932+
});
28612933
});
28622934
});

0 commit comments

Comments
 (0)