fix(angular-rspack): stop builds crashing on non-array styleUrls - #36550
Merged
Conversation
✅ Deploy Preview for nx-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for nx-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Contributor
|
View your CI Pipeline Execution ↗ for commit 7046cbc
☁️ Nx Cloud last updated this comment at |
`getAllTextByProperty` cast every `styleUrls` initializer to `ArrayLiteralExpression` and called `.getElements()` on it, so a component whose `styleUrls` was an identifier, a call, a conditional, `as const` or `satisfies` threw `array.getElements is not a function` inside a loader with no error handling and failed the build. The initializer is now guarded and those shapes contribute no URLs, which is what Angular does with them. The resolvers run whenever the Angular compilation reported no resource dependencies, so the crash was never limited to JIT. `@angular/build` 20 does not report them in any mode, so every build on that major reaches the resolvers as well. A comment claiming those dependencies were AOT-only is corrected to name both cases. The resolvers ran on `ts-morph`, which bundles its own copy of TypeScript, even though the package already declares `typescript` and uses it elsewhere. They now use the TypeScript compiler API directly, so `ts-morph` is dropped from the package and the workspace catalog without adding anything in its place. Both resolvers share one parse per file rather than parsing the same source twice. Extraction also follows `@angular/build`'s JIT resource transformer. Quoted keys such as `'styleUrls'` now resolve instead of being ignored, non-literal and empty entries no longer register dependencies on files that cannot exist, and values are read from the parsed node rather than a quote-stripped source slice, so a path containing a quote survives intact.
FrozenPandaz
approved these changes
Aug 4, 2026
ryanhefner
pushed a commit
to ryanhefner/nx
that referenced
this pull request
Aug 4, 2026
…l#36550) ## Current Behavior `@nx/angular-rspack-compiler` scrapes component `templateUrl`, `styleUrl` and `styleUrls` out of the source with `ts-morph` to register watch dependencies. Three problems: 1. `getAllTextByProperty` casts any `styleUrls` initializer to `ArrayLiteralExpression` and calls `.getElements()` on it. A `styleUrls` that is not an array literal (an identifier, a call, a conditional, `as const`, `satisfies`) throws `TypeError: array.getElements is not a function` inside a loader with no `try`/`catch`, failing the build. The scraper runs whenever the Angular compilation reported no resource dependencies, which is `aot: false` on any supported major plus every build on Angular 20, since `@angular/build` 20 never reports them. 2. The extracted URLs diverge from what Angular treats as a resource: a quoted key (`'styleUrls': [...]`) is ignored, non-literal elements and empty strings become URLs, and quotes are stripped from anywhere in the value, so `"d'accord.scss"` becomes `daccord.scss`. 3. `ts-morph` bundles its own TypeScript, so the package ships a second parser, and each file is parsed twice because the two resolvers run independently. ## Expected Behavior The resolvers use the TypeScript compiler API, follow Angular's own rules, and parse each file once. - No crash. A `styleUrls` that is not an array literal contributes no URLs. - No dependency added: `typescript` is already a direct dependency, and `@angular/build` implements these rules against the same API. Dropping `ts-morph` takes its whole tree with it, which installed on its own is 10 packages and ~15 MB, 8.7 MB of that the TypeScript copy bundled in `@ts-morph/common`. - Extraction matches `@angular/build`'s JIT resource transformer (`visitComponentMetadata`), checked against a transcription of it over 900 repo sources: 900/900 identical. Behavior changes, all in the direction of not registering a dependency on a file that cannot exist: | input | before | after | | --- | --- | --- | | `'styleUrls': ['a.scss']` (quoted key) | `[]` | `['a.scss']` | | `styleUrls: SHARED` | throws | `[]` | | `styleUrls: [SHARED, 'a.scss']` | `['SHARED', 'a.scss']` | `['a.scss']` | | `styleUrls: ['', 'a.scss']` | `['', 'a.scss']` | `['a.scss']` | | `templateUrl: CONST` | `['CONST']` | `[]` | | `templateUrl: ''` | `['']` | `[]` | | `templateUrl` as a substituted template literal | the raw source text | `[]` | | `styleUrl: "d'accord.scss"` | `['daccord.scss']` | `["d'accord.scss"]` | One deliberate deviation: Angular's `styleUrl` branch has no empty-string check while `templateUrl` and `styleUrls` entries do. We skip empty for all three, because an empty URL resolves to the component's own directory, which is not a file dependency. `getStyleUrls` and `getTemplateUrls` are exported, so the extraction change is visible to external callers. ### Performance Both resolvers per file in the loader's call order, median of 7 on node 26.3.0 and typescript 6.0.3: | corpus | path | before | after | | | --- | --- | --- | --- | --- | | 900 repo TS files | cold | 1068.9 ms | 177.5 ms | 6.0x | | 900 repo TS files | warm | 523.7 ms | 176.4 ms | 3.0x | | 200 generated components | cold | 54.0 ms | 5.8 ms | 9.3x | | 200 generated components | warm | 27.0 ms | 5.5 ms | 4.9x | Cold is a first build or a changed file. Warm is a rebuild where `TemplateUrlsResolver` returns from its cache, but `StyleUrlsResolver` calls `getStyleUrls` before consulting its own, so one parse per file remains on both sides. The parser swap accounts for the 3.0x and applies on both paths; the shared parse doubles it, and is what the warm path gives up. Output is no longer identical on both sides, per the table above, so this compares two behaviors rather than one behavior twice. ### Known gaps Both follow from parsing one file with no program, which is how these resolvers already worked. Neither is introduced or widened here, and closing either needs a type checker this path does not have. - **No `@Component` gate.** Every property assignment is scanned, so an unrelated `{ path: 'a', templateUrl: 'admin/list.html' }` also registers a dependency. Angular resolves the decorator symbol to `@angular/core`; matching the name instead would miss an aliased import and drop a watch dependency that is real, a worse failure than the spurious one it removes. Narrowed here anyway, since non-literal values no longer produce URLs. - **Angular 20 AOT.** No 20.x release reports `componentResourcesDependencies`, checked through 20.3.32, so these resolvers serve AOT builds there as well, where the compiler partially evaluates `templateUrl: CONST` and bundles a template this scan cannot see. Following that constant means partial evaluation without a program. Before this change the URL registered a phantom path, so the real file went unwatched either way, and the gap ages out with Angular 20. ### Note on the lockfile The diff also re-points a few floating ranges (`semver`, `acorn`, `tinyglobby`). A clean tree reinstalls to a zero-line diff, so that is pnpm re-resolving on a manifest change, not pre-existing staleness. ## Related Issue(s) NXC-4754 <!-- polygraph-session-start --> --- <p><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.ops.cloud.nx.app/polygraph/session-logo-v4-dark.svg"><img src="https://static.ops.cloud.nx.app/polygraph/session-logo-v4-light.svg" width="16" height="22" align="middle" alt="Polygraph"></picture> <a href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4754-83d60770">View session ↗</a></p> <!-- polygraph-session-end -->
polygraph-snapshot-app Bot
pushed a commit
that referenced
this pull request
Aug 11, 2026
) ## Current Behavior `@nx/angular-rspack-compiler` scrapes component `templateUrl`, `styleUrl` and `styleUrls` out of the source with `ts-morph` to register watch dependencies. Three problems: 1. `getAllTextByProperty` casts any `styleUrls` initializer to `ArrayLiteralExpression` and calls `.getElements()` on it. A `styleUrls` that is not an array literal (an identifier, a call, a conditional, `as const`, `satisfies`) throws `TypeError: array.getElements is not a function` inside a loader with no `try`/`catch`, failing the build. The scraper runs whenever the Angular compilation reported no resource dependencies, which is `aot: false` on any supported major plus every build on Angular 20, since `@angular/build` 20 never reports them. 2. The extracted URLs diverge from what Angular treats as a resource: a quoted key (`'styleUrls': [...]`) is ignored, non-literal elements and empty strings become URLs, and quotes are stripped from anywhere in the value, so `"d'accord.scss"` becomes `daccord.scss`. 3. `ts-morph` bundles its own TypeScript, so the package ships a second parser, and each file is parsed twice because the two resolvers run independently. ## Expected Behavior The resolvers use the TypeScript compiler API, follow Angular's own rules, and parse each file once. - No crash. A `styleUrls` that is not an array literal contributes no URLs. - No dependency added: `typescript` is already a direct dependency, and `@angular/build` implements these rules against the same API. Dropping `ts-morph` takes its whole tree with it, which installed on its own is 10 packages and ~15 MB, 8.7 MB of that the TypeScript copy bundled in `@ts-morph/common`. - Extraction matches `@angular/build`'s JIT resource transformer (`visitComponentMetadata`), checked against a transcription of it over 900 repo sources: 900/900 identical. Behavior changes, all in the direction of not registering a dependency on a file that cannot exist: | input | before | after | | --- | --- | --- | | `'styleUrls': ['a.scss']` (quoted key) | `[]` | `['a.scss']` | | `styleUrls: SHARED` | throws | `[]` | | `styleUrls: [SHARED, 'a.scss']` | `['SHARED', 'a.scss']` | `['a.scss']` | | `styleUrls: ['', 'a.scss']` | `['', 'a.scss']` | `['a.scss']` | | `templateUrl: CONST` | `['CONST']` | `[]` | | `templateUrl: ''` | `['']` | `[]` | | `templateUrl` as a substituted template literal | the raw source text | `[]` | | `styleUrl: "d'accord.scss"` | `['daccord.scss']` | `["d'accord.scss"]` | One deliberate deviation: Angular's `styleUrl` branch has no empty-string check while `templateUrl` and `styleUrls` entries do. We skip empty for all three, because an empty URL resolves to the component's own directory, which is not a file dependency. `getStyleUrls` and `getTemplateUrls` are exported, so the extraction change is visible to external callers. ### Performance Both resolvers per file in the loader's call order, median of 7 on node 26.3.0 and typescript 6.0.3: | corpus | path | before | after | | | --- | --- | --- | --- | --- | | 900 repo TS files | cold | 1068.9 ms | 177.5 ms | 6.0x | | 900 repo TS files | warm | 523.7 ms | 176.4 ms | 3.0x | | 200 generated components | cold | 54.0 ms | 5.8 ms | 9.3x | | 200 generated components | warm | 27.0 ms | 5.5 ms | 4.9x | Cold is a first build or a changed file. Warm is a rebuild where `TemplateUrlsResolver` returns from its cache, but `StyleUrlsResolver` calls `getStyleUrls` before consulting its own, so one parse per file remains on both sides. The parser swap accounts for the 3.0x and applies on both paths; the shared parse doubles it, and is what the warm path gives up. Output is no longer identical on both sides, per the table above, so this compares two behaviors rather than one behavior twice. ### Known gaps Both follow from parsing one file with no program, which is how these resolvers already worked. Neither is introduced or widened here, and closing either needs a type checker this path does not have. - **No `@Component` gate.** Every property assignment is scanned, so an unrelated `{ path: 'a', templateUrl: 'admin/list.html' }` also registers a dependency. Angular resolves the decorator symbol to `@angular/core`; matching the name instead would miss an aliased import and drop a watch dependency that is real, a worse failure than the spurious one it removes. Narrowed here anyway, since non-literal values no longer produce URLs. - **Angular 20 AOT.** No 20.x release reports `componentResourcesDependencies`, checked through 20.3.32, so these resolvers serve AOT builds there as well, where the compiler partially evaluates `templateUrl: CONST` and bundles a template this scan cannot see. Following that constant means partial evaluation without a program. Before this change the URL registered a phantom path, so the real file went unwatched either way, and the gap ages out with Angular 20. ### Note on the lockfile The diff also re-points a few floating ranges (`semver`, `acorn`, `tinyglobby`). A clean tree reinstalls to a zero-line diff, so that is pnpm re-resolving on a manifest change, not pre-existing staleness. ## Related Issue(s) NXC-4754 <!-- polygraph-session-start --> --- <p><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.ops.cloud.nx.app/polygraph/session-logo-v4-dark.svg"><img src="https://static.ops.cloud.nx.app/polygraph/session-logo-v4-light.svg" width="16" height="22" align="middle" alt="Polygraph"></picture> <a href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4754-83d60770">View session ↗</a></p> <!-- polygraph-session-end -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Current Behavior
@nx/angular-rspack-compilerscrapes componenttemplateUrl,styleUrlandstyleUrlsout of the source withts-morphto register watch dependencies. Three problems:getAllTextByPropertycasts anystyleUrlsinitializer toArrayLiteralExpressionand calls.getElements()on it. AstyleUrlsthat is not an array literal (an identifier, a call, a conditional,as const,satisfies) throwsTypeError: array.getElements is not a functioninside a loader with notry/catch, failing the build. The scraper runs whenever the Angular compilation reported no resource dependencies, which isaot: falseon any supported major plus every build on Angular 20, since@angular/build20 never reports them.'styleUrls': [...]) is ignored, non-literal elements and empty strings become URLs, and quotes are stripped from anywhere in the value, so"d'accord.scss"becomesdaccord.scss.ts-morphbundles its own TypeScript, so the package ships a second parser, and each file is parsed twice because the two resolvers run independently.Expected Behavior
The resolvers use the TypeScript compiler API, follow Angular's own rules, and parse each file once.
styleUrlsthat is not an array literal contributes no URLs.typescriptis already a direct dependency, and@angular/buildimplements these rules against the same API. Droppingts-morphtakes its whole tree with it, which installed on its own is 10 packages and ~15 MB, 8.7 MB of that the TypeScript copy bundled in@ts-morph/common.@angular/build's JIT resource transformer (visitComponentMetadata), checked against a transcription of it over 900 repo sources: 900/900 identical.Behavior changes, all in the direction of not registering a dependency on a file that cannot exist:
'styleUrls': ['a.scss'](quoted key)[]['a.scss']styleUrls: SHARED[]styleUrls: [SHARED, 'a.scss']['SHARED', 'a.scss']['a.scss']styleUrls: ['', 'a.scss']['', 'a.scss']['a.scss']templateUrl: CONST['CONST'][]templateUrl: ''[''][]templateUrlas a substituted template literal[]styleUrl: "d'accord.scss"['daccord.scss']["d'accord.scss"]One deliberate deviation: Angular's
styleUrlbranch has no empty-string check whiletemplateUrlandstyleUrlsentries do. We skip empty for all three, because an empty URL resolves to the component's own directory, which is not a file dependency.getStyleUrlsandgetTemplateUrlsare exported, so the extraction change is visible to external callers.Performance
Both resolvers per file in the loader's call order, median of 7 on node 26.3.0 and typescript 6.0.3:
Cold is a first build or a changed file. Warm is a rebuild where
TemplateUrlsResolverreturns from its cache, butStyleUrlsResolvercallsgetStyleUrlsbefore consulting its own, so one parse per file remains on both sides. The parser swap accounts for the 3.0x and applies on both paths; the shared parse doubles it, and is what the warm path gives up. Output is no longer identical on both sides, per the table above, so this compares two behaviors rather than one behavior twice.Known gaps
Both follow from parsing one file with no program, which is how these resolvers already worked. Neither is introduced or widened here, and closing either needs a type checker this path does not have.
@Componentgate. Every property assignment is scanned, so an unrelated{ path: 'a', templateUrl: 'admin/list.html' }also registers a dependency. Angular resolves the decorator symbol to@angular/core; matching the name instead would miss an aliased import and drop a watch dependency that is real, a worse failure than the spurious one it removes. Narrowed here anyway, since non-literal values no longer produce URLs.componentResourcesDependencies, checked through 20.3.32, so these resolvers serve AOT builds there as well, where the compiler partially evaluatestemplateUrl: CONSTand bundles a template this scan cannot see. Following that constant means partial evaluation without a program. Before this change the URL registered a phantom path, so the real file went unwatched either way, and the gap ages out with Angular 20.Note on the lockfile
The diff also re-points a few floating ranges (
semver,acorn,tinyglobby). A clean tree reinstalls to a zero-line diff, so that is pnpm re-resolving on a manifest change, not pre-existing staleness.Related Issue(s)
NXC-4754