Skip to content

fix(defer): defer imports used only inside @defer without deferredImports - #592

Merged
Brooooooklyn merged 5 commits into
voidzero-dev:mainfrom
GabeSilvaDev:fix/defer-lazy-dependencies
Oct 21, 2026
Merged

Brooooooklyn merged 5 commits into
voidzero-dev:mainfrom
GabeSilvaDev:fix/defer-lazy-dependencies

Conversation

@GabeSilvaDev

@GabeSilvaDev GabeSilvaDev commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #313.

A standalone component or directive listed in @Component.imports and used only inside a @defer block was compiled as an eager dependency. It stayed in ɵɵgetComponentDepsFactory, its static import stayed at the top of the file, and ɵɵdefer got no resolver, so the block did no code-splitting. Only deferredImports was lazy-loaded (#307). ngc's full compilation defers such imports implicitly (resolveDeferBlocks / registerDeferrableCandidate in the component handler.ts).

The comment left in #307 explains why OXC didn't do this: the selectors live on the imported class, in another file. CrossFileAnalyzer can now follow an import to that class, so with cross-file analysis on (resolve_imported_values, which the Vite plugin always sets) OXC does what ngc does:

import { LazyCmp } from './lazy';

@Component({
  selector: 'app-parent',
  imports: [LazyCmp],
  template: '@defer { <app-lazy/> }',
})
export class Parent {}

now compiles to

i0.ɵɵdefer(1, 0, () => [import("./lazy").then((m) => m.LazyCmp)]);
// ...
dependencies: i0.ɵɵgetComponentDepsFactory(Parent, [])

with no static import { LazyCmp }, and with ɵsetClassMetadataAsync taking LazyCmp as its callback parameter, the same output deferredImports: [LazyCmp] gives today.

Piece Change
pipeline/selector.rs CssSelector::matches, the matching half of Angular's SelectorMatcher: element, classes, attributes with and without a value, selector lists and :not. A :not(...)-only selector matches any element, and an empty one (trailing comma) matches nothing. OXC had the parser but no matcher.
component/cross_file_elision.rs CrossFileAnalyzer::standalone_directive_selector follows an import, through named re-exports, to the class declaration and reads its @Component / @Directive selector. It returns None for packages, export *, a non-literal selector or standalone, a computed key in the decorator, and a class that is not standalone. A plain class X counts only when the file exports it under that name (export { X }), so a namespace member can't point the lookup at the wrong class. It reuses the source analyze_file already read and caches each result. Files read are reported as watch dependencies, like the ones resolve_imported_values reads.
component/defer_resolver.rs implicitly_deferred_imports walks the template and builds each element's attributes the way getAttrsForDirectiveMatching does: static attributes, property and two-way bindings, outputs, and the template attributes of a structural directive. Namespace prefixes (:svg:, :xlink:) are stripped, only i18n / i18n-* are skipped, class is found in any case, and a binding replaces a static attribute of the same name. A candidate is deferred when it matches inside a @defer block's main content and nowhere else. The @placeholder / @loading / @error of a @defer nested in another block's main content count as deferred, like ngc's binder.
component/import_elision.rs Counts the value references to each import, records the non-type specifiers of each import declaration, tracks names other emitted code still needs, and can elide an import after compilation.
component/transform.rs The deferred entries join deferredImports, so they share its resolver and setClassMetadataAsync. They are taken out of the imports literal, and their static import is removed. The jit: true imports are kept in a pass before any class compiles, so the result doesn't depend on class order.

An import stays eager when:

  • it is also matched outside a @defer block, including in @placeholder, @loading and @error, which render before the chunk loads;
  • it is referenced anywhere else in the file, so the static import can't go, including by code OXC emits for another class (ctorParameters of a jit: true class, setClassMetadata constructor types);
  • another non-type specifier of the same import declaration is not deferred too, as ngc's DeferredSymbolTracker.canDefer requires (an interface imported without type counts, as in ngc);
  • its selector can't be read from source (packages, export *, a computed selector, import { X } from './x'; export { X } barrels, .js-style specifiers such as ./lazy.js);
  • it matches nothing in the template (as in ngc);
  • imports is not an array of plain identifiers (a variable or a spread);
  • HMR is on (ngc's canDeferDeps = !enableHmr);
  • the template uses selectorless components or directives;
  • it is a pipe.

Without cross-file analysis nothing changes: test_defer_with_only_imports_does_not_auto_detect still passes, and only its comment changed.

Differences from ngc

  • One resolver per component. ngc's full compilation uses DeferBlockDepsEmitMode.PerBlock, one dependency function per @defer block with only that block's dependencies. This uses the PerComponent resolver that deferredImports already emits, so the first @defer to trigger loads every implicitly deferred import. Per-block functions would need DeferMetadata::PerBlock.blocks filled before ingest, which is a larger change.
  • One import used by two components in one file stays eager. If two components in the same file both defer LazyCmp, or each defers a different specifier of one import declaration, OXC keeps them eager. ngc defers them, because it checks canDefer after every component in the file is analysed. The output is safe; it only misses a lazy load.
  • Pipes stay eager. Telling whether a pipe is used only inside @defer needs a walk over every binding expression, which OXC doesn't have yet.

Tests

tests/defer_implicit_imports_test.rs compiles real files in a temp directory with resolve_imported_values on. It has 37 cases:

  • used only inside @defer, also used outside, in a @placeholder, in a nested @switch, aliased, a default export, referenced elsewhere, an attribute directive, a bound input, not standalone, a pipe, next to an explicit deferredImports entry, HMR;
  • selector matching: SVG elements and xlink: attributes, i18n* attributes, upper-case CLASS, :not(...)-only selectors, an empty selector in a list, a binding over a static attribute, the placeholder of a nested @defer;
  • emitted code: a jit: true sibling that injects the class (declared first and last), a default import in a sibling's setClassMetadata, a computed type key, a sibling specifier used eagerly / used as a value / an interface / in deferredImports, all specifiers deferred, inline type specifiers;
  • cross-file lookup: a namespace member next to an unexported class (with and without export { LazyCmp as Alias }), class Impl {} export { Impl as LazyCmp }, a computed decorator key.

Each guard has a case that fails without it. selector.rs has unit tests for the matcher.

cargo fmt --all -- --check, cargo test --all-features (3326 passed) and cargo run -p oxc_angular_conformance (1264/1264, no snapshot diff) pass.

A standalone component or directive listed in `@Component.imports` and
matched only inside the main content of `@defer` blocks was emitted as an
eager dependency, so `@defer` did no code-splitting unless the symbol was
moved to `deferredImports`. ngc's full compilation defers it implicitly.

With cross-file analysis (`resolve_imported_values`, which the Vite plugin
sets), the selector is read from the file the symbol is imported from and
matched against the template. A symbol matched only inside `@defer` joins
the per-component resolver, leaves `ɵɵgetComponentDepsFactory`, and its
static import is removed. Placeholder, loading and error content count as
eager. The import stays eager when the selector can't be read, the symbol
is referenced elsewhere in the file, or under HMR. Pipes stay eager.

Closes voidzero-dev#313
Copilot AI balanced review requested due to automatic review settings October 21, 2026 00:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

Brooooooklyn and others added 4 commits October 21, 2026 12:21
Build each element's selector like createCssSelectorFromNode and
getAttrsForDirectiveMatching: drop namespace prefixes (`:svg:`, `:xlink:`)
from element and attribute names, skip only `i18n` and `i18n-*`, compare
`class` case-insensitively, and let a binding replace a static attribute
of the same name.

In CssSelector::matches, the `*` element of a `:not(...)`-only selector
matches any element, and an empty selector (`*`, a trailing comma)
matches nothing, as in SelectorMatcher.

The placeholder, loading and error blocks of a nested `@defer` inherit
the outer block's state, as in t2_binder's visitDeferredBlock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ations

An implicitly deferred import could be removed while other output still
named it: a `jit: true` sibling's `ctorParameters` (lost only when that
class came first in the file), a default import in a sibling's
`setClassMetadata` constructor types, or a computed type key. Those names
are now collected before any class compiles and are never deferred.

Like ngc's `DeferredSymbolTracker.canDefer`, a specifier is deferred only
when every other non-`type` specifier in its import declaration is
deferred too, so the static import really goes away.

`value_reference_count` and `elide` are now `pub(crate)`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Read a plain top-level class only when a local `export { Local }` names
  it. A namespace member in the export map could point the lookup at an
  unexported class with the same name.
- Give up on a computed key in the decorator object, since it can override
  `selector` or `standalone`.
- Reuse the source text analyze_file kept and memoize the selector per
  (file, local), cleared with the other caches. Files are still reported as
  dependencies on a cache hit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A top-level class exported under another name could still pass the
namespace-member guard, so a namespace member named like the import made
the lookup read the wrong class's selector.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 9fa794f4a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Brooooooklyn
Brooooooklyn merged commit e3810fe into voidzero-dev:main Oct 21, 2026
9 checks passed
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.

fix(defer): emit lazy dependencies for components used only inside @defer blocks

3 participants