Skip to content

fix(angular-rspack): stop builds crashing on non-array styleUrls - #36550

Merged
FrozenPandaz merged 1 commit into
masterfrom
nxc-4754
Aug 4, 2026
Merged

fix(angular-rspack): stop builds crashing on non-array styleUrls#36550
FrozenPandaz merged 1 commit into
masterfrom
nxc-4754

Conversation

@leosvelperez

@leosvelperez leosvelperez commented Aug 3, 2026

Copy link
Copy Markdown
Member

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 View session ↗

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit 7046cbc
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a71b8970c2d6b00083e88ff
😎 Deploy Preview https://deploy-preview-36550--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit 7046cbc
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a71b897cf0aa00008a7c264
😎 Deploy Preview https://deploy-preview-36550--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@leosvelperez leosvelperez self-assigned this Aug 3, 2026
@nx-cloud

nx-cloud Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 7046cbc

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ✅ Succeeded 11m 11s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 4s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 59s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 17s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 5s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-04 13:22:30 UTC

@leosvelperez
leosvelperez marked this pull request as ready for review August 3, 2026 13:33
@leosvelperez
leosvelperez requested a review from a team as a code owner August 3, 2026 13:33
@leosvelperez
leosvelperez requested a review from MaxKless August 3, 2026 13:33
`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.
@leosvelperez leosvelperez changed the title fix(angular-rspack): stop JIT builds crashing on non-array styleUrls fix(angular-rspack): stop builds crashing on non-array styleUrls Aug 4, 2026
@FrozenPandaz
FrozenPandaz merged commit dd7b498 into master Aug 4, 2026
27 of 28 checks passed
@FrozenPandaz
FrozenPandaz deleted the nxc-4754 branch August 4, 2026 18:32
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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants