Repository navigation
fix(defer): defer imports used only inside @defer without deferredImports - #592
Conversation
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
There was a problem hiding this comment.
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.
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>
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Summary
Closes #313.
A standalone component or directive listed in
@Component.importsand used only inside a@deferblock was compiled as an eager dependency. It stayed inɵɵgetComponentDepsFactory, its static import stayed at the top of the file, andɵɵdefergot no resolver, so the block did no code-splitting. OnlydeferredImportswas lazy-loaded (#307). ngc's full compilation defers such imports implicitly (resolveDeferBlocks/registerDeferrableCandidatein the componenthandler.ts).The comment left in #307 explains why OXC didn't do this: the selectors live on the imported class, in another file.
CrossFileAnalyzercan 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:now compiles to
with no static
import { LazyCmp }, and withɵsetClassMetadataAsynctakingLazyCmpas its callback parameter, the same outputdeferredImports: [LazyCmp]gives today.pipeline/selector.rsCssSelector::matches, the matching half of Angular'sSelectorMatcher: 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.rsCrossFileAnalyzer::standalone_directive_selectorfollows an import, through named re-exports, to the class declaration and reads its@Component/@Directiveselector. It returnsNonefor packages,export *, a non-literalselectororstandalone, a computed key in the decorator, and a class that is not standalone. A plainclass Xcounts 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 sourceanalyze_filealready read and caches each result. Files read are reported as watch dependencies, like the onesresolve_imported_valuesreads.component/defer_resolver.rsimplicitly_deferred_importswalks the template and builds each element's attributes the waygetAttrsForDirectiveMatchingdoes: static attributes, property and two-way bindings, outputs, and the template attributes of a structural directive. Namespace prefixes (:svg:,:xlink:) are stripped, onlyi18n/i18n-*are skipped,classis found in any case, and a binding replaces a static attribute of the same name. A candidate is deferred when it matches inside a@deferblock's main content and nowhere else. The@placeholder/@loading/@errorof a@defernested in another block's main content count as deferred, like ngc's binder.component/import_elision.rstypespecifiers of each import declaration, tracks names other emitted code still needs, and can elide an import after compilation.component/transform.rsdeferredImports, so they share its resolver andsetClassMetadataAsync. They are taken out of theimportsliteral, and their static import is removed. Thejit: trueimports are kept in a pass before any class compiles, so the result doesn't depend on class order.An import stays eager when:
@deferblock, including in@placeholder,@loadingand@error, which render before the chunk loads;ctorParametersof ajit: trueclass,setClassMetadataconstructor types);typespecifier of the same import declaration is not deferred too, as ngc'sDeferredSymbolTracker.canDeferrequires (an interface imported withouttypecounts, as in ngc);export *, a computed selector,import { X } from './x'; export { X }barrels,.js-style specifiers such as./lazy.js);importsis not an array of plain identifiers (a variable or a spread);canDeferDeps = !enableHmr);Without cross-file analysis nothing changes:
test_defer_with_only_imports_does_not_auto_detectstill passes, and only its comment changed.Differences from ngc
DeferBlockDepsEmitMode.PerBlock, one dependency function per@deferblock with only that block's dependencies. This uses thePerComponentresolver thatdeferredImportsalready emits, so the first@deferto trigger loads every implicitly deferred import. Per-block functions would needDeferMetadata::PerBlock.blocksfilled before ingest, which is a larger change.LazyCmp, or each defers a different specifier of one import declaration, OXC keeps them eager. ngc defers them, because it checkscanDeferafter every component in the file is analysed. The output is safe; it only misses a lazy load.@deferneeds a walk over every binding expression, which OXC doesn't have yet.Tests
tests/defer_implicit_imports_test.rscompiles real files in a temp directory withresolve_imported_valueson. It has 37 cases:@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 explicitdeferredImportsentry, HMR;xlink:attributes,i18n*attributes, upper-caseCLASS,:not(...)-only selectors, an empty selector in a list, a binding over a static attribute, the placeholder of a nested@defer;jit: truesibling that injects the class (declared first and last), a default import in a sibling'ssetClassMetadata, a computed type key, a sibling specifier used eagerly / used as a value / an interface / indeferredImports, all specifiers deferred, inlinetypespecifiers;export { LazyCmp as Alias }),class Impl {} export { Impl as LazyCmp }, a computed decorator key.Each guard has a case that fails without it.
selector.rshas unit tests for the matcher.cargo fmt --all -- --check,cargo test --all-features(3326 passed) andcargo run -p oxc_angular_conformance(1264/1264, no snapshot diff) pass.