Skip to content

feat(extensions): nativescript.commands map — per-command lazy loading for extensions - #6102

Open
edusperoni wants to merge 10 commits into
feat/define-commandfrom
feat/extension-manifests
Open

feat(extensions): nativescript.commands map — per-command lazy loading for extensions#6102
edusperoni wants to merge 10 commits into
feat/define-commandfrom
feat/extension-manifests

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #6101 (feat/define-command); #6099 (the DI foundation) is merged into main.

PR Checklist

What is the current behavior?

Every installed extension is eagerly require()d on every CLI invocation, before the command is even known — the extension's whole module tree loads so its top-level side effects can register commands against global.$injector. nativescript.commands in an extension's package.json is a string[] used only to suggest installs for unknown commands. Two extensions claiming the same command name crash at startup.

What is the new behavior?

nativescript.commands also accepts a map of command name → module path, which becomes authoritative:

"nativescript": {
	"commands": {
		"widget|add": "./dist/commands/widget-add.js",
		"widget|new": { "path": "./dist/commands/widget-add.js" }  // alias: same module, second entry
	}
}
  • Per-command lazy loading: nothing from the extension loads until one of its commands actually executes; the extension main is never required, and map-manifest extensions are not flagged by the deprecation tracer (the legacy array/eager path keeps working verbatim, tracer included). Values accept string | { path } so the envelope can grow additively.
  • The manifest key is authoritative: routing works before any module loads. If a loaded defineCommand definition's name disagrees with its manifest key, the CLI warns naming both and runs under the key. Aliases are duplicate manifest entries pointing at the same module.
  • registerDeferredCommand on the CommandRegistry facet: claiming a name and loading its implementation are now separate registry operations. The registry builds the command record, the parent's subcommand list, and the parent dispatcher from the name alone — a sibling's dispatch never drags in the first claimant's module — and returns a structured DeferredCommandResult (claimed / built-in / subcommand-parent / invalid-name) instead of exception text callers must match on. This is what keeps the future registry extraction a provider swap.
  • Failure UX: loader failures name the command, the owning extension, and the module path; a loader that runs but registers nothing fails the same way; non-lowercase manifest keys (permanently unreachable — dispatch lower-cases input) warn and are skipped without sinking the extension's other commands. Built-in conflicts say "already provided by the CLI", no internals.
  • Deterministic defaults: *default entries sort first per parent in code — JSON key order carries no meaning. First-wins conflict resolution is defined in the docs (extension load order, alphabetical; the mid-load ns extension install exception documented). Re-declaring a command under the same owner is a no-op, so ns extension install <already-installed> no longer warns about conflicting with itself. "commands": {} opts out of loading entirely.
  • A command module may self-register on load (legacy shape) or simply export a defineCommand definition — one registration code path (registerDefinitionAs) serves both the manifest loader and registerCommandDefinition.
  • ILazyRequireProvider is no longer part of the exported Provider union (container-internal).
  • New authoring guide: extensions.md — leads with the peerDependency + devDependency on nativescript and inject() from nativescript/contracts.

Public type names follow the new-API convention (no I prefix): DeferredCommandOptions, DeferredCommandResult, DeferredCommandRejection.

25 tests in test/extension-manifests.ts (lazy registration, eager-path preservation, malformed/conflict/self-conflict handling, both suggestion shapes, pure-definition modules incl. resolving the parent dispatcher before any child module has loaded, key-mismatch warning, alias entries, {} opt-out). Full stacked suite: 116 files, 1784 passed / 9 skipped; yok oracle, public-API test, and compat fixtures untouched.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 813328f0-f302-4b63-a7e8-5876ea6e99ae

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@edusperoni
edusperoni force-pushed the feat/define-command branch from b15734b to 863f964 Compare July 30, 2026 01:39
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from 220e027 to 02d6f7c Compare July 30, 2026 01:41
@edusperoni
edusperoni force-pushed the feat/define-command branch from 863f964 to 74caa8f Compare July 30, 2026 01:44
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from 02d6f7c to d8a8fcf Compare July 30, 2026 01:44
@edusperoni
edusperoni force-pushed the feat/define-command branch from 74caa8f to cafa737 Compare July 30, 2026 02:27
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from d8a8fcf to e0c671c Compare July 30, 2026 02:28
@edusperoni
edusperoni force-pushed the feat/define-command branch from cafa737 to a1ba0ef Compare July 30, 2026 02:51
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from e0c671c to 10aaa87 Compare July 30, 2026 02:52
@edusperoni
edusperoni force-pushed the feat/define-command branch from a1ba0ef to 07c979c Compare August 4, 2026 20:41
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from 10aaa87 to 7bbf81e Compare August 4, 2026 20:41
@edusperoni
edusperoni force-pushed the feat/define-command branch from 07c979c to d712a4e Compare August 5, 2026 19:22
…s map

An extension whose package.json declares nativescript.commands as a map of
command name to module path is no longer require()d at startup. Each entry is
registered with injector.requireCommand against the module's absolute path, so
a command's implementation loads only when that command is first resolved, and
the CLI stops paying every installed extension's load cost on every
invocation.

Entries are validated: a command name or module path that is not a non-empty
string is warned about and skipped, and a name already claimed by another
extension is reported as a warning naming both extensions rather than
propagating the injector's "require'd twice" failure.

The legacy array shape (and a missing commands key) keeps today's behavior
verbatim - eager require of the extension main plus the
extensions.require-time-registration deprecation report. Both shapes now feed
IExtensionData.commands and the npm install suggestion for unknown commands.
A manifest entry may now point at a module that exports a defineCommand
definition instead of registering itself on load: the deferred loader
adapts and registers the export under the manifest key. The override
also lands on a parent record the entry just created, because dispatch
resolves the hierarchical parent before any child module has loaded and
the dispatcher only comes into existence once a child registers.

Also cross-links the authoring guides from dependency-injection.md.
… seam

The service takes $injector as a constructor dependency instead of the
module-level import, so manifest registration and the definition-aware
loaders target the instance that resolved it. Tests assert on their own
per-test injector; the process-wide injector is swapped only because
legacy-shape fixture modules register through the published global
surface at load, and that seam is labeled as such.

extensions.md no longer teaches the global-injector patterns: the legacy
array path and self-registering modules are described under their
deprecation framing without runnable samples.
Registry operations go through the narrow subsystem contract; the full
facade stays only for container-record operations (has, provider
registration). First consumer of the per-face tokens.
…iner

A record carrying only a lazy-require loader resolves to an error until the
loader registers something onto it, so the form is not one callers should be
offered: drop ILazyRequireProvider from the exported Provider union and keep it
in an InternalProvider alias the container accepts.

Add hasResolver() so the deferred paths can tell a record that a loader has
filled in from one it left empty.
Claiming a command name and loading its implementation are now separate: the
registry builds routing — the command record, the parent's subcommand list and
the parent dispatcher — from the name alone, and runs the loader only when that
one command is resolved. A sibling's dispatch no longer drags in the first
claimant's module, and the outcome comes back as a structured result instead of
a thrown message callers have to match on.

Names that are not lower case are rejected: dispatch lower-cases what the user
typed, so they could never be reached. A loader that throws, or that leaves the
command without a resolver, fails naming the owner and the source.

Extract registerDefinitionAs so a definition registered under a name chosen by
its registrant is built exactly like one registered under its own.
The manifest loader no longer writes injector records or reads exception text
to detect conflicts; it hands each entry to registerDeferredCommand and reports
the rejection it gets back. A command claimed by another extension names that
extension, one the CLI provides says so without exposing internals, and
re-loading an already loaded extension is silent rather than a conflict with
itself.

Entry values may now be an object carrying the module path under `path`, with
unrecognised keys ignored, so the shape can grow without stranding manifests on
released CLIs. Default commands are registered ahead of their siblings so JSON
key order carries no meaning.

The manifest key is what the command is dispatched as — routing happens before
the module exists — so a definition whose own name disagrees runs under the key
and warns naming both, and definitions register through the same helper as
registerCommandDefinition.
Lead with the peerDependency + devDependency pair that makes
`require("nativescript/contracts")` resolve and keeps a second CLI copy out of
the tree, and teach inject() as the way to reach a CLI service.

Cover what the manifest actually promises: the key is authoritative for
routing, aliases are duplicate entries pointing at one module, entry values may
be envelopes, an empty map opts out of loading, keys must be lower case, and
"first" in first-wins is the order extensions load in. Drop the JSON key-order
constraint, which no longer exists.
@edusperoni
edusperoni force-pushed the feat/extension-manifests branch from 7bbf81e to bcd08f0 Compare August 5, 2026 19:44
@edusperoni
edusperoni marked this pull request as ready for review August 5, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant