Skip to content

HMR component updates silently fail in multi-project workspaces #33005

@ChristofFritz

Description

@ChristofFritz

Command

serve

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

No response

Description

Disclaimer: This issue was co-authored with claude code

Component HMR silently fails in Angular workspaces where the application is located in a subdirectory (e.g. projects/my-app/) rather than the workspace root.

The angular:component-update WebSocket event is correctly sent to the browser and the client-side import.meta.hot.on('angular:component-update', ...) listener is registered, but the update is never applied because the component IDs don't match between server and client.

Root cause: The HMR update ID is computed using different base directories on each side:

  • Server (aot-compilation.ts): relative(host.getCurrentDirectory(), componentFilename) — getCurrentDirectory() returns the project directory (projects/management/), producing src/app/foo/foo.component.ts
  • Client (compiler-cli, extractHmrMetatadata → getProjectRelativePath): uses rootDirs derived from compilerOptions.rootDir, which points to the workspace root, producing projects/management/src/app/foo/foo.component.ts

The encoded IDs never match, so d.id === id in the client-side handler always evaluates to false.

This is related to #32998 which describes the same symptom triggered by an explicit rootDir setting.

Fix: In aot-compilation.ts, resolve compilerOptions.rootDir relative to getCurrentDirectory() before computing the relative path:

const hmrBaseDir = compilerOptions.rootDir                                                                                                                                                                                                                                      
  ? resolve(host.getCurrentDirectory(), compilerOptions.rootDir)
  : host.getCurrentDirectory();
let relativePath = relative(hmrBaseDir, componentFilename);

Minimal Reproduction

Here is a repo with a minimal reproduction.

  1. ng new repro-workspace --no-create-application
  2. cd repro-workspace
  3. ng generate application my-app --project-root projects/my-app
  4. Move the project config into a per-project angular.json (to simulate a multi-project workspace where each project has its own angular.json):
    - Clear projects in the root angular.json to {}
    - Create projects/my-app/angular.json with the project definition, using paths relative to projects/my-app/ (i.e. "root": ".", "browser": "src/main.ts", "tsConfig": "tsconfig.app.json")
    - Add "hmr": true and "liveReload": true to the serve options
  5. cd projects/my-app && npx ng serve my-app
  6. Modify src/app/app.component.html
  7. Observe terminal: Component update sent to client(s).
  8. Observe browser WebSocket: angular:component-update event received with ID starting with src%2Fapp%2F...
  9. Observe browser source code: client-side HMR listener registered with ID starting with projects%2Fmy-app%2Fsrc%2Fapp%2F...
  10. Expected: Component updates in the browser
  11. Actual: Nothing happens. The server-side ID (src/app/...) doesn't match the client-side ID (projects/my-app/src/app/...) because host.getCurrentDirectory() in aot-compilation.ts returns the project subdirectory, while the compiler's getProjectRelativePath resolves
    against rootDirs (the workspace root via rootDir: "." in the root tsconfig.json).

The root cause is in packages/angular/build/src/tools/angular/compilation/aot-compilation.ts: the HMR update ID is computed as relative(host.getCurrentDirectory(), componentFilename), but the client-side ID uses getProjectRelativePath(fileName, rootDirs, compilerHost)
which resolves against compilerOptions.rootDir. When the project's angular.json is in a subdirectory, these produce different paths.

Exception or Error


Your Environment

_                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI       : 21.2.6
Angular           : 21.2.7
Node.js           : 24.14.1
Package Manager   : pnpm 10.33.0
Operating System  : darwin arm64

┌───────────────────────────────┬───────────────────┬───────────────────┐
│ Package                       │ Installed Version │ Requested Version │
├───────────────────────────────┼───────────────────┼───────────────────┤
│ @angular-devkit/build-angular │ 21.2.6            │ 21.2.6            │
│ @angular-devkit/core          │ 21.2.6            │ 21.2.6            │
│ @angular/build                │ 21.2.6            │ 21.2.6            │
│ @angular/cli                  │ 21.2.6            │ 21.2.6            │
│ @angular/compiler             │ 21.2.7            │ 21.2.7            │
│ @angular/compiler-cli         │ 21.2.7            │ 21.2.7            │
│ @angular/core                 │ 21.2.7            │ 21.2.7            │
│ @angular/platform-browser     │ 21.2.7            │ 21.2.7            │
│ ng-packagr                    │ 21.2.2            │ 21.2.2            │
│ typescript                    │ 5.9.3             │ 5.9.3             │
│ vitest                        │ 4.1.4             │ 4.1.4             │
│ zone.js                       │ 0.15.1            │ 0.15.1            │
└───────────────────────────────┴───────────────────┴───────────────────┘

Anything else relevant?

Tested on macOS Darwin 24.1.0. Package manager: pnpm.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions