diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml
index 94fdfb8df840..f21e2612ef53 100644
--- a/.github/workflows/assistant-to-the-branch-manager.yml
+++ b/.github/workflows/assistant-to-the-branch-manager.yml
@@ -2,6 +2,7 @@ name: DevInfra
on:
push:
+ # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code}
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, labeled]
@@ -17,6 +18,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: angular/dev-infra/github-actions/branch-manager@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ - uses: angular/dev-infra/github-actions/branch-manager@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 57b31cb9c2f1..eb33533f00e2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Generate JSON schema types
@@ -44,11 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -61,11 +61,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -84,13 +84,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Run CLI E2E tests
@@ -100,11 +100,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -137,7 +137,7 @@ jobs:
runs-on: windows-2025
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Download built Windows E2E tests
@@ -164,13 +164,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Run CLI E2E tests
@@ -188,13 +188,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Run CLI E2E tests
@@ -208,34 +208,35 @@ jobs:
SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }}
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
+ - name: Start Sauce Connect
+ uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3
+ timeout-minutes: 10
+ with:
+ username: ${{ vars.SAUCE_USERNAME }}
+ accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
+ region: us-west-1
+ tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }}
+ proxyLocalhost: allow
+ # By default we disable SSL bumping for all requests. This is because SSL bumping is not needed
+ # for our test setup and in order to perform the SSL bumping, Saucelabs intercepts all HTTP
+ # requests in the tunnel VM and modifies them. This can cause flakiness as it makes all requests
+ # dependent on the SSL bumping middleware.
+ # See: https://wiki.saucelabs.com/display/DOCS/Troubleshooting+Sauce+Connect#TroubleshootingSauceConnect-DisablingSSLBumping
+ tlsPassthroughDomains: all
- name: Run E2E Browser tests
env:
SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
- SAUCE_LOG_FILE: /tmp/angular/sauce-connect.log
- SAUCE_READY_FILE: /tmp/angular/sauce-connect-ready-file.lock
- SAUCE_PID_FILE: /tmp/angular/sauce-connect-pid-file.lock
- SAUCE_TUNNEL_IDENTIFIER: 'angular-${{ github.run_number }}'
- SAUCE_READY_FILE_TIMEOUT: 120
- run: |
- ./scripts/saucelabs/start-tunnel.sh &
- ./scripts/saucelabs/wait-for-tunnel.sh
- pnpm bazel test --config=saucelabs //tests:e2e.saucelabs
- ./scripts/saucelabs/stop-tunnel.sh
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- if: ${{ failure() }}
- with:
- name: sauce-connect-log
- path: ${{ env.SAUCE_CONNECT_DIR_IN_HOST }}/sauce-connect.log
+ run: pnpm bazel test --config=saucelabs //tests:e2e.saucelabs
publish-snapshots:
needs: build
@@ -244,11 +245,11 @@ jobs:
CIRCLE_BRANCH: ${{ github.ref_name }}
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- run: pnpm admin snapshots --verbose
env:
SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index f95ab2f4ac05..15fc3e9c9ba5 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -23,12 +23,12 @@ jobs:
with:
persist-credentials: false
- name: Initialize CodeQL
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: javascript-typescript
build-mode: none
config-file: .github/codeql/config.yml
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: '/language:javascript-typescript'
diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml
index e0efdf2b94e4..2c9e701b95e2 100644
--- a/.github/workflows/dev-infra.yml
+++ b/.github/workflows/dev-infra.yml
@@ -1,6 +1,7 @@
name: DevInfra
on:
+ # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code}
pull_request_target:
types: [opened, synchronize, reopened]
issues:
@@ -15,21 +16,21 @@ jobs:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- - uses: angular/dev-infra/github-actions/labeling/pull-request@649c3afeaa46674507b9625537e49de54a695e2b
+ - uses: angular/dev-infra/github-actions/labeling/pull-request@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
post_approval_changes:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- - uses: angular/dev-infra/github-actions/post-approval-changes@649c3afeaa46674507b9625537e49de54a695e2b
+ - uses: angular/dev-infra/github-actions/post-approval-changes@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
issue_labels:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- - uses: angular/dev-infra/github-actions/labeling/issue@649c3afeaa46674507b9625537e49de54a695e2b
+ - uses: angular/dev-infra/github-actions/labeling/issue@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }}
diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml
index dac67ebcbe7f..591414f2e95a 100644
--- a/.github/workflows/perf.yml
+++ b/.github/workflows/perf.yml
@@ -9,7 +9,6 @@ on:
permissions:
contents: 'read'
- id-token: 'write'
defaults:
run:
@@ -23,7 +22,7 @@ jobs:
workflows: ${{ steps.workflows.outputs.workflows }}
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- id: workflows
@@ -33,14 +32,17 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: list
+ permissions:
+ contents: 'read'
+ id-token: 'write'
strategy:
matrix:
workflow: ${{ fromJSON(needs.list.outputs.workflows) }}
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
# We utilize the google-github-actions/auth action to allow us to get an active credential using workflow
@@ -52,4 +54,7 @@ jobs:
project_id: 'internal-200822'
workload_identity_provider: 'projects/823469418460/locations/global/workloadIdentityPools/measurables-tracking/providers/angular'
service_account: 'measures-uploader@internal-200822.iam.gserviceaccount.com'
- - run: pnpm ng-dev perf workflows --name ${{ matrix.workflow }} --commit-sha ${{github.sha}}
+ - run: pnpm ng-dev perf workflows --name "$WORKFLOW" --commit-sha "$COMMIT_SHA"
+ env:
+ WORKFLOW: ${{ matrix.workflow }}
+ COMMIT_SHA: ${{ github.sha }}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0f5a37f5751a..ad9364286682 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
@@ -34,9 +34,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup ESLint Caching
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@@ -66,17 +66,17 @@ jobs:
# it has been merged.
run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }}
- name: Check Package Licenses
- uses: angular/dev-infra/github-actions/linting/licenses@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/linting/licenses@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
build:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Build release targets
@@ -93,11 +93,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run module and package tests
@@ -114,13 +114,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Run CLI E2E tests
run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }}
@@ -128,11 +128,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Build E2E tests for Windows on Linux
@@ -156,7 +156,7 @@ jobs:
runs-on: windows-2025
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Download built Windows E2E tests
@@ -183,13 +183,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Run CLI E2E tests
run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }}
@@ -205,12 +205,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
- uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Setup Bazel
- uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/setup@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Setup Bazel RBE
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
+ uses: angular/dev-infra/github-actions/bazel/configure-remote@04230133d395dfb032d782b8e63b4fcbbd406aa5 # main
- name: Run CLI E2E tests
run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 2e9f8b03919a..862ead79f856 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: 'Run analysis'
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
@@ -46,6 +46,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
- uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
sarif_file: results.sarif
diff --git a/.nvmrc b/.nvmrc
index f9e7451e7715..60ade1ae01e8 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22.23.1
+24.19.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8867117ff498..fca9a3af30cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,172 +1,460 @@
-
+
-# 22.0.8 (2026-07-23)
+# 22.2.0-next.3 (2026-08-13)
### @angular/cli
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- |
-| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting |
-| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| [7106676e6](https://github.com/angular/angular-cli/commit/7106676e64ee234641b1b3cbd88fc058703d4d3e) | fix | disable searching current directory for bare executable names on Windows |
+| [ecf8c0822](https://github.com/angular/angular-cli/commit/ecf8c0822e5d56b44a0be4be8fccb41373135d3e) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) |
+| [2e4dd90c3](https://github.com/angular/angular-cli/commit/2e4dd90c3ae898bf36af42f699c7b7d560bfc66a) | perf | avoid eager module loading during global bootstrap |
+
+### @schematics/angular
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- |
+| [1161e6c99](https://github.com/angular/angular-cli/commit/1161e6c99992c884e2e0ce9fc295b0e60b2ae1df) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md |
+| [c536ae364](https://github.com/angular/angular-cli/commit/c536ae364975dd0088fd8717464c723a4dcb74e3) | fix | import UrlSegment instead of subPath in guard generator |
### @angular/build
| Commit | Type | Description |
| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- |
-| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows |
-| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers |
-| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled |
-| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest |
-| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction |
-| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled |
+| [04b532772](https://github.com/angular/angular-cli/commit/04b532772f6347d341b182135dee1758616a69d6) | fix | count statically imported chunks in the initial total |
+| [2274babb6](https://github.com/angular/angular-cli/commit/2274babb662b8da2189f1ae9faaa528838052c32) | fix | normalize setupFiles paths to POSIX for vitest runner |
+| [199a864df](https://github.com/angular/angular-cli/commit/199a864df97fc8c20b8d4e1fc8b891c61140671a) | fix | prevent syntax corruption for Crockford-style enum IIFE |
+| [0d9851600](https://github.com/angular/angular-cli/commit/0d9851600ca618ccabfc3143a9c552a5448d6eec) | fix | recursively ignore output and cache paths in watch mode |
+| [3eba6f726](https://github.com/angular/angular-cli/commit/3eba6f7269f3b0f8556510d0b668e978ea686c88) | fix | return direct file contents for non-Angular TypeScript files |
+| [3c7ac1518](https://github.com/angular/angular-cli/commit/3c7ac1518b062f14409b4d3c6c3e65c956424ad9) | fix | return only lowest version per target engine |
+| [3e9fed9a6](https://github.com/angular/angular-cli/commit/3e9fed9a6a1d24f30812ca29ca621d9d9a14496f) | fix | set target for Rolldown dependency prebundling in Vite dev server |
+| [87551ad5c](https://github.com/angular/angular-cli/commit/87551ad5c53f90cbdc68fdb5e47007b9436c7f75) | perf | avoid encoding intermediate source maps before remapping |
+| [194be2088](https://github.com/angular/angular-cli/commit/194be2088587918ed7b47ed606ea587a7116608a) | perf | avoid encoding the inline source map before remapping |
+| [a55a6b78e](https://github.com/angular/angular-cli/commit/a55a6b78e89debb9c93c83375c54f6716bc26c65) | perf | batch last_accessed updates in sqlite cache store |
+| [33b305416](https://github.com/angular/angular-cli/commit/33b3054169929dc98f93a758a90da5d26cf522f7) | perf | hash the i18n inline cache key options once per locale |
+| [1c00edce0](https://github.com/angular/angular-cli/commit/1c00edce02bdd0201546417d71518f78bfb89d34) | perf | optimize sourcemap stripping and loading with buffer fast path |
+| [b6269a816](https://github.com/angular/angular-cli/commit/b6269a8169046a78281808dcf92054140912a77d) | perf | optimize template string size calculation in server manifest |
+| [a6ef9cfbe](https://github.com/angular/angular-cli/commit/a6ef9cfbeace725d58c0f7f65640ef6de9b39c33) | perf | replace watchpack with @parcel/watcher and chokidar |
+| [596847f89](https://github.com/angular/angular-cli/commit/596847f89913684a84465d004aa3d6c7e1a40b06) | perf | share i18n translations with the inliner workers by reference |
+| [19c91e48d](https://github.com/angular/angular-cli/commit/19c91e48d4b8a95a76dfe4bffd105130b416843d) | perf | use Map for chunk asset size lookups in budget calculator |
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- |
+| [6a3446770](https://github.com/angular/angular-cli/commit/6a3446770a8fbfecc7265eb2a6d764e2f1614b3a) | fix | destroy platform when response stream is cancelled |
-
+
-# 22.1.0-next.4 (2026-07-15)
+# 22.1.4 (2026-08-13)
### @angular/cli
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ |
-| [3789ceee1](https://github.com/angular/angular-cli/commit/3789ceee18b4528c2acb7989b5d41c32d386a8a8) | fix | copy packageManager field and yarn config for temp installs |
-| [b0b3a47ae](https://github.com/angular/angular-cli/commit/b0b3a47ae95f4e98e1280ff5c9e3751ccecee5a8) | fix | support resolving subproject dependencies in pnpm workspaces |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| [67a29899b](https://github.com/angular/angular-cli/commit/67a29899b07f760bac9582cbc6175966cce1c5d2) | fix | disable searching current directory for bare executable names on Windows |
+| [1b0ba5c17](https://github.com/angular/angular-cli/commit/1b0ba5c17f0be2679b31bad1bee8b435315dac1a) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) |
+| [9440432d2](https://github.com/angular/angular-cli/commit/9440432d273c044965508d80e808c2fe123af0aa) | perf | avoid eager module loading during global bootstrap |
### @schematics/angular
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- |
-| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- |
+| [d4a48e3ab](https://github.com/angular/angular-cli/commit/d4a48e3abe320e090eefe17cb70abdf8e7eafaed) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md |
+| [d451f15b2](https://github.com/angular/angular-cli/commit/d451f15b2838a8282363dcb30161dd5dc858750f) | fix | import UrlSegment instead of subPath in guard generator |
### @angular/build
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- |
+| [46fcb29d6](https://github.com/angular/angular-cli/commit/46fcb29d6c1ded962de95449d63716c13950f59a) | fix | count statically imported chunks in the initial total |
+| [796b57684](https://github.com/angular/angular-cli/commit/796b576843191a4d5c74e4f9363ec0e46ae3a881) | fix | normalize setupFiles paths to POSIX for vitest runner |
+| [c805e5cfb](https://github.com/angular/angular-cli/commit/c805e5cfb308d02a1944ef446d0a01af8a7ec6d2) | fix | prevent syntax corruption for Crockford-style enum IIFE |
+| [cc6c17716](https://github.com/angular/angular-cli/commit/cc6c17716df5c411efca5e6f4c960f0565f45d90) | fix | prevent syntax corruption in oxc transform |
+| [a142f6f83](https://github.com/angular/angular-cli/commit/a142f6f83d2f501b64f2404909ef9325bbfa70fb) | fix | return direct file contents for non-Angular TypeScript files |
+| [956c6578d](https://github.com/angular/angular-cli/commit/956c6578d31d8482c04dc45723d7ab3b4fe355a5) | fix | return only lowest version per target engine |
+| [689b5b116](https://github.com/angular/angular-cli/commit/689b5b116717140da98494e61ac5ea5ad7b68d73) | fix | set target for Rolldown dependency prebundling in Vite dev server |
+| [70461f6ed](https://github.com/angular/angular-cli/commit/70461f6ed2e30aeaad2b93400ad00d9274665967) | perf | avoid encoding the inline source map before remapping |
+| [22cce7563](https://github.com/angular/angular-cli/commit/22cce7563e82f17373b69e590d3e445767bd9264) | perf | batch last_accessed updates in sqlite cache store |
+| [ab2ed18d5](https://github.com/angular/angular-cli/commit/ab2ed18d5739e8565403062ca74aa2d5cb3b95ec) | perf | hash the i18n inline cache key options once per locale |
+| [34516ff73](https://github.com/angular/angular-cli/commit/34516ff736185e20ef8239b1fed8eda1f0193f62) | perf | optimize template string size calculation in server manifest |
+| [712971c37](https://github.com/angular/angular-cli/commit/712971c370738beaebff18f6995859b1713ed8ae) | perf | share i18n translations with the inliner workers by reference |
+| [78f8c5fef](https://github.com/angular/angular-cli/commit/78f8c5fef44dc6ff466b17c98fa1d01222cb1781) | perf | use Map for chunk asset size lookups in budget calculator |
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- |
+| [2331c1047](https://github.com/angular/angular-cli/commit/2331c10476830e7a53e0f2fa4a2f37dad58e2d5d) | fix | destroy platform when response stream is cancelled |
+
+
+
+
+
+# 21.2.21 (2026-08-12)
+
+### @angular/cli
+
| Commit | Type | Description |
| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ |
-| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes |
-| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments |
+| [ba5d16cac](https://github.com/angular/angular-cli/commit/ba5d16cacab4139897d1f0cf4161562b1bd00553) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 |
+
+### @angular/build
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | --------------------- |
+| [f7f60e69e](https://github.com/angular/angular-cli/commit/f7f60e69e77cda60378543a9ca9cc2c76c0bf34f) | fix | bump undici to 7.29.0 |
-
+
-# 22.0.7 (2026-07-15)
+# 20.3.34 (2026-08-12)
### @angular/cli
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ |
-| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs |
-| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ |
+| [3962517b9](https://github.com/angular/angular-cli/commit/3962517b95c76016ff7bcb53662704dff18c0552) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 |
+
+### @angular-devkit/build-angular
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | --------------------- |
+| [f348efe8b](https://github.com/angular/angular-cli/commit/f348efe8b6d9c9d7014afb331eecda0a9478ba0a) | fix | bump undici to 7.29.0 |
-
+
-# 21.2.19 (2026-07-09)
+# 22.2.0-next.2 (2026-08-05)
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- |
+| [7e5510e464](https://github.com/angular/angular-cli/commit/7e5510e464eb423d8757ae6f058f73fc17249d81) | fix | disable linker internal source mapping in OXC linker |
+| [bad31421cb](https://github.com/angular/angular-cli/commit/bad31421cbf23339d4fbd389a91d198df405697d) | fix | prevent syntax corruption in linker and oxc transform |
+
+
+
+
+
+# 22.2.0-next.1 (2026-08-05)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| [41555dfb3b](https://github.com/angular/angular-cli/commit/41555dfb3b71d08cdfe2853bf2cbeca5b6942f67) | feat | add `--root` command line option to `mcp` command |
+| [035b9d5c9b](https://github.com/angular/angular-cli/commit/035b9d5c9bca624b98e14480f599481f294b49f3) | fix | add packages with built-in schematics to devDependencies only |
+| [0ae5690c35](https://github.com/angular/angular-cli/commit/0ae5690c35b9af00f60d4851f1bab4ba5ec8eb19) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) |
+| [44ff584b33](https://github.com/angular/angular-cli/commit/44ff584b33830aa839c08feb205f014e4e7b5dca) | fix | correctly report available package updates during bare ng update |
+| [1d4f9ebbe9](https://github.com/angular/angular-cli/commit/1d4f9ebbe993a5eb8b23929a3fc55b506597e5aa) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) |
+| [25034f97ad](https://github.com/angular/angular-cli/commit/25034f97ad1b9a12f4080f646a0e2705425f9726) | fix | normalize Windows drive-letter casing for process.cwd |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- |
+| [ad8271320f](https://github.com/angular/angular-cli/commit/ad8271320f67c2b1502e446f1e3cb0a05444d04f) | feat | migrate Angular Linker to oxc-parser and magic-string |
+| [4fb12292c2](https://github.com/angular/angular-cli/commit/4fb12292c21498c59836f7c80e4f824bb7751cac) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) |
+| [abd8ec9f3e](https://github.com/angular/angular-cli/commit/abd8ec9f3e11f1410fcb87d0d9d60f56016912fe) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files |
+| [bcadbbef20](https://github.com/angular/angular-cli/commit/bcadbbef2054e897b9d22c1456883fc68f0383c0) | fix | retain watch files on error in load result cache |
+| [e1c7193dce](https://github.com/angular/angular-cli/commit/e1c7193dcee1c7f2feb07fdf24a6e0656f29cfaf) | fix | support case-insensitive and alternative license file names |
+| [f4a0f846e7](https://github.com/angular/angular-cli/commit/f4a0f846e7d14b5ff58c91d04461ef4a027c56b8) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) |
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- |
+| [b74eef889f](https://github.com/angular/angular-cli/commit/b74eef889f9c8812dcbe116400663fa3d68d6b4f) | fix | settle writeResponseToNodeResponse when client disconnects |
+| [33169801c6](https://github.com/angular/angular-cli/commit/33169801c6ed7fc7c7dca2c04008e5d405374983) | perf | avoid buffering request body when sanitizing headers |
+
+
+
+
+
+# 22.1.3 (2026-08-05)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| [f837b788c6](https://github.com/angular/angular-cli/commit/f837b788c6a23e7661db5455192f5a84d20e9b37) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) |
+| [72ae93638d](https://github.com/angular/angular-cli/commit/72ae93638dd83b3877773ac0d9f7e4c4e9056a33) | fix | correctly report available package updates during bare ng update |
+| [a949e06568](https://github.com/angular/angular-cli/commit/a949e0656855786e4274159be09242ff9c4eb012) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) |
+| [1d9122fd50](https://github.com/angular/angular-cli/commit/1d9122fd50f8a9b82199d44f9b0e075726279538) | fix | normalize Windows drive-letter casing for process.cwd |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- |
+| [a4860d505a](https://github.com/angular/angular-cli/commit/a4860d505aff0b43fc608cd0745ed187b1d407b5) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) |
+| [7686d66cab](https://github.com/angular/angular-cli/commit/7686d66cabcdfe90748ac405686d42674a19576f) | fix | retain watch files on error in load result cache |
+| [25b7cf7504](https://github.com/angular/angular-cli/commit/25b7cf7504838d541abfba043d9ea3d616a23c04) | fix | support case-insensitive and alternative license file names |
+| [4fd81402ce](https://github.com/angular/angular-cli/commit/4fd81402ce5f2f3db45a17ee0d116f12edf8d254) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) |
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- |
+| [6af906cb94](https://github.com/angular/angular-cli/commit/6af906cb94bd0686b4c0431989e054711c74892a) | fix | settle writeResponseToNodeResponse when client disconnects |
+| [917d3b2254](https://github.com/angular/angular-cli/commit/917d3b22549c19b91e03ceb0775a3a758142b9de) | perf | avoid buffering request body when sanitizing headers |
+
+
+
+
+
+# 21.2.20 (2026-08-05)
### @angular-devkit/build-angular
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- |
-| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 |
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- |
+| [972f6db7e9](https://github.com/angular/angular-cli/commit/972f6db7e9ccf3c4b0e895341a49b104a8b0ac9b) | fix | upgrade postcss to 8.5.23 |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- |
+| [8de75ada50](https://github.com/angular/angular-cli/commit/8de75ada503d4182af7f0be957e58fe678a63b98) | fix | upgrade postcss to 8.5.23 |
-
+
-# 20.3.32 (2026-07-09)
+# 20.3.33 (2026-08-05)
### @angular-devkit/build-angular
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- |
-| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 |
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- |
+| [0b4008f17d](https://github.com/angular/angular-cli/commit/0b4008f17d76c821d9f075b86da80b826f085d23) | fix | upgrade postcss to 8.5.23 |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- |
+| [fc861affcd](https://github.com/angular/angular-cli/commit/fc861affcd8866b71b81c8ba90f7a63890b6577c) | fix | upgrade postcss to 8.5.23 |
-
+
+
+# 22.1.2 (2026-07-30)
-# 22.1.0-next.3 (2026-07-09)
+### @angular/cli
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- |
+| [57db27ea91](https://github.com/angular/angular-cli/commit/57db27ea910b102854267dfcd77e8fe60f23993c) | fix | add packages with built-in schematics to devDependencies only |
### @angular/build
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ |
-| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback |
-| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees |
-| [41bd100dd](https://github.com/angular/angular-cli/commit/41bd100dd8edebdf6c7279e1c7d3847ee5f22702) | fix | fallback to build target preserveSymlinks option in karma runner |
-| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds |
-| [85c53d990](https://github.com/angular/angular-cli/commit/85c53d9909b8bcb35a360044a0afde5352d45d72) | fix | prevent externalizing builder-injected i18n locale data |
-| [004cc4104](https://github.com/angular/angular-cli/commit/004cc410487b2dbb80001f045cd19f899c980ccc) | fix | strip all vite id prefixes from minified code with external dependencies |
-| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation |
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ |
+| [baed978885](https://github.com/angular/angular-cli/commit/baed978885585fceadc64f0151d7474054d57d6a) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files |
-### @angular/ssr
+
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- |
-| [0bd22dae0](https://github.com/angular/angular-cli/commit/0bd22dae09a83e36ed3863ca2b050666cc8d7aec) | fix | replace all route parameters when resolving relative redirects |
+
+
+# 22.1.1 (2026-07-30)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- |
+| [ab971a8543](https://github.com/angular/angular-cli/commit/ab971a8543d80dfc93fdddf49598564f25196160) | fix | support npm 12 metadata array and error formats |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- |
+| [db24220fb8](https://github.com/angular/angular-cli/commit/db24220fb8efb5c9416e375a468672ff69d0659d) | fix | rewrite paths from sandboxed execroots when running under Bazel |
-
+
-# 22.0.6 (2026-07-08)
+# 22.2.0-next.0 (2026-07-30)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- |
+| [e148a8df93](https://github.com/angular/angular-cli/commit/e148a8df93b309dcd51698b4ce4f9343560edd8e) | fix | support npm 12 metadata array and error formats |
### @angular/build
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ |
-| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner |
-| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data |
-| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies |
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- |
+| [6469b5f1f5](https://github.com/angular/angular-cli/commit/6469b5f1f5946415f49000f26bddd88b5647ac3d) | fix | rewrite paths from sandboxed execroots when running under Bazel |
+
+
+
+
+
+# 22.1.0 (2026-07-29)
+
+## Deprecations
+
+### @angular-devkit/core
+
+- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead.
+
+ Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations.
+
+### @schematics/angular
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- |
+| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config |
+| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir |
+
+### @angular/cli
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- |
+| [f59bcf529](https://github.com/angular/angular-cli/commit/f59bcf52917bc5e25b97c863f96127ec8d0f12f5) | fix | abort and guide manual mitigation when updating catalog packages |
+| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override |
+| [c963ecbec](https://github.com/angular/angular-cli/commit/c963ecbec241d42dfb419ae081ba22429e37f09e) | fix | remove algoliasearch dependency and support latest docs versions |
+| [6859d25c3](https://github.com/angular/angular-cli/commit/6859d25c32b6c93cbbf2d4ad4143aa10b52b7588) | fix | resolve correct registry name when using npm alias syntax during update |
+| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution |
+| [5c3101bff](https://github.com/angular/angular-cli/commit/5c3101bff2e1b9d0b0e4668a183e8bcab8a1540e) | fix | revert package.json changes if installation tasks fail during update |
+| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array |
+
+### @angular-devkit/core
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- |
+| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString |
+
+### @angular/build
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- |
+| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback |
+| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes |
+| [51f69276f](https://github.com/angular/angular-cli/commit/51f69276fd2b2aab0e94901e79a51f6860ef8690) | feat | enable chunk optimization for server builds |
+| [10dc30f9c](https://github.com/angular/angular-cli/commit/10dc30f9c680f46f65b5beb030f2a75c422a3e71) | feat | migrate advanced optimization Babel plugins to oxc-parser + magic-string |
+| [917393a4c](https://github.com/angular/angular-cli/commit/917393a4cd85172574b155f727da5c1eae196fb1) | feat | migrate i18n inliner to oxc-parser + magic-string |
+| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees |
+| [95510511d](https://github.com/angular/angular-cli/commit/95510511d963384c5a03298300246aa34a62328a) | fix | add bounded timeout to vitest executor disposal |
+| [62b11e316](https://github.com/angular/angular-cli/commit/62b11e316e8248161877592acee5954b2a8c9c66) | fix | anchor debug ID comment matching and make injection idempotent |
+| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds |
+| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments |
+| [0e3ca3dfb](https://github.com/angular/angular-cli/commit/0e3ca3dfba8ef60217089d93a321660ebb6fb838) | fix | support hyphens and underscores when stripping chunk hash in chunk optimizer |
+| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown |
+| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation |
### @angular/ssr
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- |
-| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- |
+| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header |
-
+
-# 22.1.0-next.2 (2026-07-01)
+# 22.0.9 (2026-07-29)
### @angular/cli
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- |
+| [9ee8f9b669](https://github.com/angular/angular-cli/commit/9ee8f9b6692b8efa2d0cabd502cd5a598f436c8a) | fix | declare devserver_start and devserver_stop as isReadOnly:false |
+
+### @angular/build
+
+| Commit | Type | Description |
+| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- |
+| [d0c776d461](https://github.com/angular/angular-cli/commit/d0c776d4610e2327c29900b9b3d679e9c0f28061) | fix | safeguard Karma builder stream controller against closed state |
+
+
+
+
+
+# 22.0.8 (2026-07-23)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- |
+| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting |
+| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex |
+
+### @angular/build
+
| Commit | Type | Description |
| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- |
-| [010cef665](https://github.com/angular/angular-cli/commit/010cef6657365cad8529c164d7c594ca598180a0) | fix | gracefully handle package manager errors in command handler |
-| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution |
-| [edb4594de](https://github.com/angular/angular-cli/commit/edb4594deb5ff96967c1ce13b4494e3fd675ebec) | fix | respect release age policy in update bootstrapping logic |
+| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows |
+| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers |
+| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled |
+| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest |
+| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction |
+| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled |
-### @schematics/angular
+
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- |
-| [0b3172e07](https://github.com/angular/angular-cli/commit/0b3172e076b75df5b8c3066a115d1151f8b0bad9) | fix | remove unused app tsconfig outDir |
+
+
+# 22.0.7 (2026-07-15)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ |
+| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs |
+| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces |
+
+
+
+
+
+# 21.2.19 (2026-07-09)
### @angular-devkit/build-angular
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ |
-| [2d3eb7fb3](https://github.com/angular/angular-cli/commit/2d3eb7fb3b43b64c476296581481dbf67cb57b1d) | fix | prevent OS command injection in ssr-dev-server builder |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- |
+| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 |
+
+
+
+
+
+# 20.3.32 (2026-07-09)
+
+### @angular-devkit/build-angular
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- |
+| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 |
+
+
+
+
+
+# 22.0.6 (2026-07-08)
### @angular/build
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- |
-| [75c1dce38](https://github.com/angular/angular-cli/commit/75c1dce387075d3ea9d6da572f7daa589856bc0e) | fix | auto-inject localize/init in library unit tests |
-| [dc27e17fc](https://github.com/angular/angular-cli/commit/dc27e17fc2cbb91b10b508a7e46cd72734959ffa) | fix | inherit preserveSymlinks option from build target in unit-test builder |
-| [61e25fb1d](https://github.com/angular/angular-cli/commit/61e25fb1db56694da800f330dd65cb7e50ea2d11) | fix | load zone.js dynamically for library unit tests |
-| [0010b922f](https://github.com/angular/angular-cli/commit/0010b922f8fc5a9c57b9512594f9c3b4da3fa2ae) | fix | scope createRequire module resolution using paths to prevent parent paths |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ |
+| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner |
+| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data |
+| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies |
### @angular/ssr
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- |
-| [d9fb854c9](https://github.com/angular/angular-cli/commit/d9fb854c9a48ba8852d57f3fa8f7a9640ee0aab2) | fix | preserve response headers during redirect |
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- |
+| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects |
@@ -247,43 +535,6 @@
-
-
-# 22.1.0-next.1 (2026-06-24)
-
-### @angular/cli
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- |
-| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override |
-| [38e0fabc0](https://github.com/angular/angular-cli/commit/38e0fabc05eb5d622a25c68692d7d073485750e3) | fix | support registry metadata fetching under bun package manager |
-| [3ce6e5fc3](https://github.com/angular/angular-cli/commit/3ce6e5fc3b4f4b5fb6ac99cc82023bf01894d14c) | perf | implement semaphore backpressure throttling in PackageManager |
-
-### @schematics/angular
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ |
-| [01008f1c6](https://github.com/angular/angular-cli/commit/01008f1c69321d0a8b5b8344672b594a68fe83b6) | fix | remove default workspace vscode mcp.json configuration |
-
-### @angular/build
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- |
-| [851711b03](https://github.com/angular/angular-cli/commit/851711b031603293b58e33357c70eea7675452e3) | fix | aggregate parallel worker performance timings on the main thread |
-| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown |
-| [3019c8032](https://github.com/angular/angular-cli/commit/3019c8032ba9462d0edc614940b26ade24e45ec8) | perf | dispose builder result context early in non-watch mode |
-| [583736a4d](https://github.com/angular/angular-cli/commit/583736a4de8b3d0b4d79709a445809c264e38c17) | perf | implement semaphore backpressure throttling in JavaScriptTransformer |
-
-### @angular/ssr
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- |
-| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header |
-| [37f1a7491](https://github.com/angular/angular-cli/commit/37f1a749132d55a81cec231bd260ed280b10fc00) | fix | avoid caching non-SSG page lookups |
-| [5875b6024](https://github.com/angular/angular-cli/commit/5875b6024ff9e59fddf7481b6b961d73ae809963) | fix | prioritize options over environment variables in AngularNodeAppEngine |
-
-
-
# 22.0.4 (2026-06-24)
@@ -402,44 +653,6 @@
-
-
-# 22.1.0-next.0 (2026-06-11)
-
-## Deprecations
-
-### @angular-devkit/core
-
-- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead.
-
- Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations.
-
-### @schematics/angular
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- |
-| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config |
-
-### @angular/cli
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- |
-| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array |
-
-### @angular-devkit/core
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- |
-| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString |
-
-### @angular/ssr
-
-| Commit | Type | Description |
-| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ |
-| [ddcef9d48](https://github.com/angular/angular-cli/commit/ddcef9d484e205c5fcfd93d184425c1c1af19c34) | fix | correct grammar in console warning for redirected location headers |
-
-
-
# 20.3.28 (2026-06-11)
diff --git a/MODULE.bazel b/MODULE.bazel
index 4b82a12dc781..13363a8bf513 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -7,48 +7,48 @@ module(
bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "yq.bzl", version = "0.3.6")
bazel_dep(name = "rules_nodejs", version = "6.7.5")
-bazel_dep(name = "aspect_rules_js", version = "3.2.3")
-bazel_dep(name = "aspect_rules_ts", version = "3.9.2")
-bazel_dep(name = "rules_pkg", version = "1.2.0")
+bazel_dep(name = "aspect_rules_js", version = "3.4.0")
+bazel_dep(name = "aspect_rules_ts", version = "3.10.0")
+bazel_dep(name = "rules_pkg", version = "1.3.0")
bazel_dep(name = "rules_cc", version = "0.2.22")
bazel_dep(name = "jq.bzl", version = "0.6.1")
-bazel_dep(name = "bazel_lib", version = "3.5.0")
-bazel_dep(name = "bazel_skylib", version = "1.9.0")
-bazel_dep(name = "aspect_rules_esbuild", version = "0.26.0")
+bazel_dep(name = "bazel_lib", version = "3.7.1")
+bazel_dep(name = "bazel_skylib", version = "1.9.2")
+bazel_dep(name = "aspect_rules_esbuild", version = "0.27.0")
bazel_dep(name = "aspect_rules_jasmine", version = "2.0.4")
bazel_dep(name = "rules_angular")
git_override(
module_name = "rules_angular",
- commit = "db445dccfe48aad20be6ee3df456df91660ff217",
+ commit = "20a373d609c4f5765b9ad367a205f3a635dd2cda",
remote = "https://github.com/angular/rules_angular.git",
)
bazel_dep(name = "devinfra")
git_override(
module_name = "devinfra",
- commit = "daa0b5ecd15668d12163f70d4c9836ada70808c9",
+ commit = "04230133d395dfb032d782b8e63b4fcbbd406aa5",
remote = "https://github.com/angular/dev-infra.git",
)
bazel_dep(name = "rules_browsers")
git_override(
module_name = "rules_browsers",
- commit = "3a6bd40f9be945dc51c976a21424c656448f9b09",
+ commit = "37853f23de9a9a70f53c02a9baa27e08d7d12003",
remote = "https://github.com/angular/rules_browsers.git",
)
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(
node_repositories = {
- "22.23.1-darwin_arm64": ("node-v22.23.1-darwin-arm64.tar.gz", "node-v22.23.1-darwin-arm64", "ef28d8fab2c0e4314522d4bb1b7173270aa3937e93b92cb7de79c112ac1fa953"),
- "22.23.1-darwin_amd64": ("node-v22.23.1-darwin-x64.tar.gz", "node-v22.23.1-darwin-x64", "b8da981b8a0b1241b70249204916da76c63573ddf5814dbd2d1e41069105cb81"),
- "22.23.1-linux_arm64": ("node-v22.23.1-linux-arm64.tar.xz", "node-v22.23.1-linux-arm64", "0294e8b915ab75f92c7513d2fcb830ae06e10684e6c603e99a87dbf8835389c1"),
- "22.23.1-linux_ppc64le": ("node-v22.23.1-linux-ppc64le.tar.xz", "node-v22.23.1-linux-ppc64le", "a645bcb7bbc498d09ba9249b5c6414aa6d0a461d8854afc61e32096646ba5e66"),
- "22.23.1-linux_s390x": ("node-v22.23.1-linux-s390x.tar.xz", "node-v22.23.1-linux-s390x", "f16b5636ba925b462e627c5e0ef47de3255bf4a7b38d56db58e2cf165df19c3c"),
- "22.23.1-linux_amd64": ("node-v22.23.1-linux-x64.tar.xz", "node-v22.23.1-linux-x64", "9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578"),
- "22.23.1-windows_amd64": ("node-v22.23.1-win-x64.zip", "node-v22.23.1-win-x64", "7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29"),
+ "24.19.0-darwin_arm64": ("node-v24.19.0-darwin-arm64.tar.gz", "node-v24.19.0-darwin-arm64", "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d"),
+ "24.19.0-darwin_amd64": ("node-v24.19.0-darwin-x64.tar.gz", "node-v24.19.0-darwin-x64", "d1b5e999db158c62fe8f7267a4476b035d8bd93b1a605bac24a3f0dd166e3316"),
+ "24.19.0-linux_arm64": ("node-v24.19.0-linux-arm64.tar.xz", "node-v24.19.0-linux-arm64", "01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc"),
+ "24.19.0-linux_ppc64le": ("node-v24.19.0-linux-ppc64le.tar.xz", "node-v24.19.0-linux-ppc64le", "c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3"),
+ "24.19.0-linux_s390x": ("node-v24.19.0-linux-s390x.tar.xz", "node-v24.19.0-linux-s390x", "a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4"),
+ "24.19.0-linux_amd64": ("node-v24.19.0-linux-x64.tar.xz", "node-v24.19.0-linux-x64", "14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647"),
+ "24.19.0-windows_amd64": ("node-v24.19.0-win-x64.zip", "node-v24.19.0-win-x64", "57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73"),
},
- node_version = "22.23.1",
+ node_version = "24.19.0",
)
use_repo(
node,
@@ -131,8 +131,8 @@ use_repo(
pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
pnpm.pnpm(
name = "pnpm",
- pnpm_version = "11.15.1",
- pnpm_version_integrity = "sha512-gTULB+U8lTigLx8jA7QpD6LXvgTlbiqXDEzEtBfcdh3hlu2r1J1Vx9yVgNuBAHxEFD5OPX5GKzAA0jwlUSLQZQ==",
+ pnpm_version = "11.21.0",
+ pnpm_version_integrity = "sha512-UhcFvOaJkk6scvWjWHEi82JonvZXHlW6gAdv1jfBETLs/62ib61Op5xIW/3b/T1aKlsFgFp36JPeceyKbMo7sQ==",
)
use_repo(pnpm, "pnpm")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index eb446604633b..4542fcb3d759 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -19,15 +19,18 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_rules_esbuild/0.26.0/MODULE.bazel": "6c902d97038c3ab07b6c4e67c97abc61b20182fcfa84fa7dee82fc724f12e455",
- "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.26.0/source.json": "4cc3ece7ab661bb391a9e24fe55c4b567d60a9ea9d9e91d772dad373cbcb6217",
+ "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.0/MODULE.bazel": "877dafc0b925f8af19e8bc2abed04a757bb565c57c1866e8851ac4d15ed5e6d2",
+ "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.0/source.json": "21f8738b3e62310ef43b7cef4284e1bafd69bd8e4e50251b71b20bbfed4372d8",
"https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/MODULE.bazel": "fbb819eb8b7e5d7f67fdd38f7cecb413e287594cd666ce192c72c8828527775a",
"https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/source.json": "81ffb708333cd98ec3c0b4cc004f4d5cf92a16914b5196a2892c45141bba7cff",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/3.0.3/MODULE.bazel": "28a30e8fc33bf64a67835d64d124f6e05a7d59648dcb27b110fb3502f761e503",
- "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/MODULE.bazel": "3a0363a5b8ec4931488dc327c577a78c1f5a725293ebffaa5d2613283701aa8a",
- "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/source.json": "fa3187962f5fdeee0c6226792904aaafb2cf585717a264fa4393a767bffb99fe",
+ "https://bcr.bazel.build/modules/aspect_rules_js/3.3.1/MODULE.bazel": "3e02b51b503ba8dda69b043290f6cc11add9aeb8db0bf1f6c861c396c7ddc5b2",
+ "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/MODULE.bazel": "88844ac411e1961f4574a92f3c5be5b20d1c6997778c6b88316c5c3b4b60e284",
+ "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/source.json": "85e5822f00dcbe64a1eda1324119e289c8c03cacb5c3695dffee16397b529078",
+ "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.0/MODULE.bazel": "69d06f57f30f4a2b6e53471584a9559d3b7cd7f891e1699876991230c7cabb95",
+ "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.0/source.json": "56f28a3ddb55ceaaf57a1ef8d7195136789ca1d72d0c8a6a9eeaad313be4099d",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/MODULE.bazel": "feeb6c45b69c995eca3e5ca5872658c80df658022e01044eca00cf472bb89142",
- "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/source.json": "cf3075502f798f71a9c5707a7684eaf0b86da11ed440fd90ea34385dc297676a",
"https://bcr.bazel.build/modules/aspect_tools_telemetry/0.2.8/MODULE.bazel": "aa975a83e72bcaac62ee61ab12b788ea324a1d05c4aab28aadb202f647881679",
"https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183",
"https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca",
@@ -52,8 +55,9 @@
"https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
"https://bcr.bazel.build/modules/bazel_lib/3.2.2/MODULE.bazel": "e2c890c8a515d6bca9c66d47718aa9e44b458fde64ec7204b8030bf2d349058c",
- "https://bcr.bazel.build/modules/bazel_lib/3.5.0/MODULE.bazel": "1eba0ba67d418d99fb682c36147a9e1725abbc8fc25544be64702e32eda2be29",
- "https://bcr.bazel.build/modules/bazel_lib/3.5.0/source.json": "98d49ec7e2b15298c990f09ec8cdea8d4788c2885497aa9cbb0a74e36e994df8",
+ "https://bcr.bazel.build/modules/bazel_lib/3.7.0/MODULE.bazel": "d7c10ed67f0f7f1fda179db8f86c22642581bd614882e1a50545fbe069525173",
+ "https://bcr.bazel.build/modules/bazel_lib/3.7.1/MODULE.bazel": "b6fd9b2f8fab956420c11836f416efac4a70e20804ae384ebe62773a4ed70046",
+ "https://bcr.bazel.build/modules/bazel_lib/3.7.1/source.json": "635fdaa28b50c04febc5e60ef51bc913d3bc87bfbaac7045449273c2341648cb",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -69,7 +73,8 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
- "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
+ "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/MODULE.bazel": "8c51259b0f4481475586dbfede7591e57b75702e637f840f6138eec80c34b270",
+ "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/source.json": "41cbde7546542dee2f26e3f10bf1c4ac57909943194b7ac48cc5238a01893aa8",
"https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
"https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
@@ -171,7 +176,8 @@
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
- "https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
+ "https://bcr.bazel.build/modules/rules_pkg/1.3.0/MODULE.bazel": "ae0bdefbacc990c91f843206c90cf0f4be620639a5bf22119043599ba86d51a3",
+ "https://bcr.bazel.build/modules/rules_pkg/1.3.0/source.json": "58ae84c545141762f7c434c0ce78bfb55ef0be08d84863e7f0503fff135fe4e2",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f",
@@ -222,8 +228,8 @@
"moduleExtensions": {
"@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": {
"general": {
- "bzlTransitiveDigest": "DE8ssop85SA4kEHMbyc2ajgEW3TNsQFbT4L6IfuwTZo=",
- "usagesDigest": "LSQ+zZp7JNgnBONTxxXnwGr4NTh2qtQYk7qwXXz5qWo=",
+ "bzlTransitiveDigest": "eGsGosHZTHOEZx11j7nDLNMHxCkESoe1oypcL3+G6GI=",
+ "usagesDigest": "LZ71sshnfqI8R/HeWwBTwawxIa7KnitayBl2hYrqPo4=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -438,7 +444,7 @@
"@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
"general": {
"bzlTransitiveDigest": "4w9RM0xjdKo1crk5zL20a/TuhqO0P1z1LsuXDneBXD4=",
- "usagesDigest": "hqK7Zzp+y5jlPhecF8Z77KSrR9zpTS+H88Epy3pRc/s=",
+ "usagesDigest": "VoGyqZ+r/3juGZUfSXD4FAe965Z3XS4WANRrYH79+1Q=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {
@@ -449,13 +455,13 @@
"repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository",
"attributes": {
"deps": {
- "aspect_rules_js": "3.2.3",
- "aspect_rules_ts": "3.9.2",
- "aspect_rules_esbuild": "0.26.0",
+ "aspect_rules_js": "3.4.0",
+ "aspect_rules_ts": "3.10.0",
+ "aspect_rules_esbuild": "0.27.0",
"aspect_rules_jasmine": "2.0.4",
"aspect_tools_telemetry": "0.4.2"
},
- "last_notice": 0
+ "last_notice": 1
}
}
},
@@ -539,7 +545,7 @@
},
"@@rules_browsers+//browsers:extensions.bzl%browsers": {
"general": {
- "bzlTransitiveDigest": "3W9m9j39szqkOoSVVqamCaSwCCw7x5Wq1kcYTwYBNOc=",
+ "bzlTransitiveDigest": "4TUvIB8juKCShOmy6m77drIdimJqOTEPwfzMxkWD5a4=",
"usagesDigest": "FmXYJVoVJlnfUU8x8gObSvu4qWcco/9Faw61aC/wBF0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -548,9 +554,9 @@
"rules_browsers_chrome_linux": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "13d6638183ccab406ee16e28730826bd3ea57fe564a741bc935401221dfec64f",
+ "sha256": "5b132ebe0be5c0c15cb5222c33e04e37ae21ed3e00d84c68e905f80f29221c50",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/linux64/chrome-headless-shell-linux64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/linux64/chrome-headless-shell-linux64.zip"
],
"named_files": {
"CHROME-HEADLESS-SHELL": "chrome-headless-shell-linux64/chrome-headless-shell"
@@ -566,9 +572,9 @@
"rules_browsers_chrome_mac": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "b50ee6ddb3bf30c91f3094fa41cc8e8de60e317c63ef058db2375346cde78d98",
+ "sha256": "522e62dbfce61fddd413fb76a65e699b6f5bdce4045e4398d1e2fb1ef73ff6f3",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-x64/chrome-headless-shell-mac-x64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/mac-x64/chrome-headless-shell-mac-x64.zip"
],
"named_files": {
"CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-x64/chrome-headless-shell"
@@ -584,9 +590,9 @@
"rules_browsers_chrome_mac_arm": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "49859c1d3b0d29a82c58765e9e5fe21efa0bf7c03a86fc91eb5f3f94f34c45b6",
+ "sha256": "b68d54b63ab30042874a87ad81a135360fa87e1d4763df2431b30b4d59878b4e",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-arm64/chrome-headless-shell-mac-arm64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/mac-arm64/chrome-headless-shell-mac-arm64.zip"
],
"named_files": {
"CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-arm64/chrome-headless-shell"
@@ -602,9 +608,9 @@
"rules_browsers_chrome_win64": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "d161d88a999effd212ce87df2ff710be1a25a495cc428d4c3029feccd02dafe9",
+ "sha256": "0fd5b488d41686cb7cb0be1feb861f1e4cb44d9c5e67a4c5328a385742aa6a8e",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/win64/chrome-headless-shell-win64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/win64/chrome-headless-shell-win64.zip"
],
"named_files": {
"CHROME-HEADLESS-SHELL": "chrome-headless-shell-win64/chrome-headless-shell.exe"
@@ -620,9 +626,9 @@
"rules_browsers_chromedriver_linux": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "7bb2eedf7030523ba617704bf6c3e472b5974d048d7fa6ac6a764904556c6f36",
+ "sha256": "69139f654d93c12d04209713c725b33ea7074a1e35c47aaf98f419c4a00b4e9b",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/linux64/chromedriver-linux64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/linux64/chromedriver-linux64.zip"
],
"named_files": {
"CHROMEDRIVER": "chromedriver-linux64/chromedriver"
@@ -636,9 +642,9 @@
"rules_browsers_chromedriver_mac": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "5c1fffef90ecea2bb0c12c5a9c0549116fc7acda13cce15881da4aa8b74e6794",
+ "sha256": "b56b49cb41658c25405757b3c01a5412403e3744ee6935c5f9f59bf2b907bcf6",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-x64/chromedriver-mac-x64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/mac-x64/chromedriver-mac-x64.zip"
],
"named_files": {
"CHROMEDRIVER": "chromedriver-mac-x64/chromedriver"
@@ -652,9 +658,9 @@
"rules_browsers_chromedriver_mac_arm": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "a33d54c99a50fef25cbedb7c3d3cdd838dd784556138648bcc5da5814109a306",
+ "sha256": "e2bd843b45ba197632d29eeef188940488779684207eb3118a5a78c26aef9659",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/mac-arm64/chromedriver-mac-arm64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/mac-arm64/chromedriver-mac-arm64.zip"
],
"named_files": {
"CHROMEDRIVER": "chromedriver-mac-arm64/chromedriver"
@@ -668,9 +674,9 @@
"rules_browsers_chromedriver_win64": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "2673d6a87dabd50c516d4d69b4daf742ff6169877c04bb226971503012262d8c",
+ "sha256": "2f56f10ba3989d8b5c44c22cff080c2cc436314f8f8babbcc151bb95b34de841",
"urls": [
- "https://storage.googleapis.com/chrome-for-testing-public/152.0.7960.0/win64/chromedriver-win64.zip"
+ "https://storage.googleapis.com/chrome-for-testing-public/153.0.7993.0/win64/chromedriver-win64.zip"
],
"named_files": {
"CHROMEDRIVER": "chromedriver-win64/chromedriver.exe"
@@ -684,9 +690,9 @@
"rules_browsers_firefox_linux": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "748704b06ffaabc8bd2b3351c93c244db4e29bfe21af6ecb4d6c124c7bd4a234",
+ "sha256": "bfc57e7b6b4e6204b11e7e03c4b93cff708e9fb37f6b9948be243455311d82ee",
"urls": [
- "https://archive.mozilla.org/pub/firefox/releases/152.0/linux-x86_64/en-US/firefox-152.0.tar.xz"
+ "https://archive.mozilla.org/pub/firefox/releases/153.0/linux-x86_64/en-US/firefox-153.0.tar.xz"
],
"named_files": {
"FIREFOX": "firefox/firefox"
@@ -700,9 +706,9 @@
"rules_browsers_firefox_mac": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "b8a46188850d2fb32f16ad3c0829b08cd689bc714b8ee18fd9b09bb60629004d",
+ "sha256": "2f9b5a20e546e7e79e4182f8fe10353a3e251635963ab3f6d399a3f290adeb96",
"urls": [
- "https://archive.mozilla.org/pub/firefox/releases/152.0/mac/en-US/Firefox%20152.0.dmg"
+ "https://archive.mozilla.org/pub/firefox/releases/153.0/mac/en-US/Firefox%20153.0.dmg"
],
"named_files": {
"FIREFOX": "Firefox.app/Contents/MacOS/firefox"
@@ -716,9 +722,9 @@
"rules_browsers_firefox_mac_arm": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "b8a46188850d2fb32f16ad3c0829b08cd689bc714b8ee18fd9b09bb60629004d",
+ "sha256": "2f9b5a20e546e7e79e4182f8fe10353a3e251635963ab3f6d399a3f290adeb96",
"urls": [
- "https://archive.mozilla.org/pub/firefox/releases/152.0/mac/en-US/Firefox%20152.0.dmg"
+ "https://archive.mozilla.org/pub/firefox/releases/153.0/mac/en-US/Firefox%20153.0.dmg"
],
"named_files": {
"FIREFOX": "Firefox.app/Contents/MacOS/firefox"
@@ -732,9 +738,9 @@
"rules_browsers_firefox_win64": {
"repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo",
"attributes": {
- "sha256": "3c426623ff83cb014402607c7ec99fea5b39eec2e44a07f639680c393ff9784e",
+ "sha256": "b409ff5af3419214f11f928e67b1f44c1ad0f8a46f7db7e697caedefa02a6d2a",
"urls": [
- "https://archive.mozilla.org/pub/firefox/releases/152.0/win64/en-US/Firefox%20Setup%20152.0.exe"
+ "https://archive.mozilla.org/pub/firefox/releases/153.0/win64/en-US/Firefox%20Setup%20153.0.exe"
],
"named_files": {
"FIREFOX": "core/firefox.exe"
@@ -1175,7 +1181,7 @@
"@@yq.bzl+//yq:extensions.bzl%yq": {
"general": {
"bzlTransitiveDigest": "UfFMy8CWK4/dVo/tfaSAIYUiDGNAPes5eRllx9O9Q9Q=",
- "usagesDigest": "Sllnnruao+9gE+ivwtmfqL78yZPGXm4HVMOBoVEIW0U=",
+ "usagesDigest": "9kbJ6OfvQpAM3otgkGgS5xpOHOoVgV5ZJ9jSgMgUG2g=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
diff --git a/constants.bzl b/constants.bzl
index 6987637a4442..143ee7243735 100644
--- a/constants.bzl
+++ b/constants.bzl
@@ -3,10 +3,10 @@ RELEASE_ENGINES_NODE = "^22.22.3 || ^24.15.0 || >=26.0.0"
RELEASE_ENGINES_NPM = "^6.11.0 || ^7.5.6 || >=8.0.0"
RELEASE_ENGINES_YARN = ">= 1.13.0"
-NG_PACKAGR_VERSION = "^22.1.0-next.0"
-ANGULAR_FW_VERSION = "^22.1.0-next.0"
-ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.1.0-next.0"
-NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.1.0-next.0"
+NG_PACKAGR_VERSION = "^22.2.0-next.0"
+ANGULAR_FW_VERSION = "^22.2.0-next.0"
+ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.2.0-next.0"
+NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.2.0-next.0"
# Baseline widely-available date in `YYYY-MM-DD` format which defines Angular's
# browser support. This date serves as the source of truth for the Angular CLI's
diff --git a/goldens/public-api/angular_devkit/build_webpack/index.api.md b/goldens/public-api/angular_devkit/build_webpack/index.api.md
index abe0725a36fe..3e7ebf07873b 100644
--- a/goldens/public-api/angular_devkit/build_webpack/index.api.md
+++ b/goldens/public-api/angular_devkit/build_webpack/index.api.md
@@ -6,6 +6,7 @@
import { BuilderContext } from '@angular-devkit/architect';
import { BuilderOutput } from '@angular-devkit/architect';
+import type { Configuration } from 'webpack-dev-server';
import { Observable } from 'rxjs';
import type webpack from 'webpack';
import type WebpackDevServer from 'webpack-dev-server';
@@ -50,7 +51,7 @@ export function runWebpack(config: webpack.Configuration, context: BuilderContex
// @public @deprecated (undocumented)
export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: {
shouldProvideStats?: boolean;
- devServerConfig?: WebpackDevServer.Configuration;
+ devServerConfig?: Configuration;
logging?: WebpackLoggingCallback;
webpackFactory?: WebpackFactory;
webpackDevServerFactory?: WebpackDevServerFactory;
diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json
index 9aede9dc7d10..1a7fa852247a 100644
--- a/modules/testing/builder/package.json
+++ b/modules/testing/builder/package.json
@@ -7,8 +7,8 @@
"@vitest/coverage-v8": "4.1.10",
"browser-sync": "3.0.4",
"istanbul-lib-instrument": "6.0.3",
- "jsdom": "29.1.1",
- "ng-packagr": "22.1.0-next.3",
+ "jsdom": "30.0.1",
+ "ng-packagr": "22.2.0-next.2",
"rxjs": "7.8.2",
"vitest": "4.1.10"
}
diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json
index 0ec9802c2c70..c8105b2c16b4 100644
--- a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json
+++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json
@@ -2,7 +2,7 @@
"name": "lib",
"version": "0.0.1",
"peerDependencies": {
- "@angular/common": "^22.1.0-next",
- "@angular/core": "^22.1.0-next"
+ "@angular/common": "^22.2.0-next",
+ "@angular/core": "^22.2.0-next"
}
}
\ No newline at end of file
diff --git a/package.json b/package.json
index f67f33227c76..d4a9d8d057bc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@angular/devkit-repo",
- "version": "22.1.0-next.4",
+ "version": "22.2.0-next.3",
"private": true,
"description": "Software Development Kit for Angular",
"keywords": [
@@ -28,12 +28,12 @@
"type": "git",
"url": "git+https://github.com/angular/angular-cli.git"
},
- "packageManager": "pnpm@11.15.1",
+ "packageManager": "pnpm@11.21.0",
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
"npm": "Please use pnpm instead of NPM to install dependencies",
"yarn": "Please use pnpm instead of Yarn to install dependencies",
- "pnpm": "11.15.1"
+ "pnpm": "11.21.0"
},
"author": "Angular Authors",
"license": "MIT",
@@ -42,23 +42,23 @@
},
"homepage": "https://github.com/angular/angular-cli",
"dependencies": {
- "@angular/compiler-cli": "22.1.0-rc.0",
+ "@angular/compiler-cli": "22.2.0-next.1",
"typescript": "6.0.3"
},
"devDependencies": {
- "@angular/animations": "22.1.0-rc.0",
- "@angular/cdk": "22.1.0-next.4",
- "@angular/common": "22.1.0-rc.0",
- "@angular/compiler": "22.1.0-rc.0",
- "@angular/core": "22.1.0-rc.0",
- "@angular/forms": "22.1.0-rc.0",
- "@angular/localize": "22.1.0-rc.0",
- "@angular/material": "22.1.0-next.4",
- "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#7a67d5221a9efec2b69b102759d04223a3fc8552",
- "@angular/platform-browser": "22.1.0-rc.0",
- "@angular/platform-server": "22.1.0-rc.0",
- "@angular/router": "22.1.0-rc.0",
- "@angular/service-worker": "22.1.0-rc.0",
+ "@angular/animations": "22.2.0-next.1",
+ "@angular/cdk": "22.2.0-next.0",
+ "@angular/common": "22.2.0-next.1",
+ "@angular/compiler": "22.2.0-next.1",
+ "@angular/core": "22.2.0-next.1",
+ "@angular/forms": "22.2.0-next.1",
+ "@angular/localize": "22.2.0-next.1",
+ "@angular/material": "22.2.0-next.0",
+ "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#c71d9b6af7560faa3d002534d416a8045111adae",
+ "@angular/platform-browser": "22.2.0-next.1",
+ "@angular/platform-server": "22.2.0-next.1",
+ "@angular/router": "22.2.0-next.1",
+ "@angular/service-worker": "22.2.0-next.1",
"@babel/core": "8.0.1",
"@bazel/bazelisk": "1.28.1",
"@bazel/buildifier": "8.2.1",
@@ -70,7 +70,7 @@
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "16.0.3",
- "@rollup/wasm-node": "4.62.2",
+ "@rollup/wasm-node": "4.62.4",
"@stylistic/eslint-plugin": "^5.0.0",
"@tony.ganchev/eslint-plugin-header": "~3.4.0",
"@types/babel__core": "7.20.5",
@@ -89,21 +89,20 @@
"@types/picomatch": "^4.0.0",
"@types/progress": "^2.0.3",
"@types/semver": "^7.3.12",
- "@types/watchpack": "^2.4.4",
"@types/yargs": "^17.0.20",
"@types/yargs-parser": "^21.0.0",
- "@typescript-eslint/eslint-plugin": "8.64.0",
- "@typescript-eslint/parser": "8.64.0",
+ "@typescript-eslint/eslint-plugin": "8.67.0",
+ "@typescript-eslint/parser": "8.67.0",
"ajv": "8.20.0",
"buffer": "6.0.3",
- "esbuild": "0.28.1",
- "esbuild-wasm": "0.28.1",
- "eslint": "10.7.0",
+ "esbuild": "0.28.2",
+ "esbuild-wasm": "0.28.2",
+ "eslint": "10.8.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"express": "5.2.1",
"fast-glob": "3.3.3",
- "globals": "17.7.0",
+ "globals": "17.11.0",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "4.2.0",
"husky": "9.1.7",
@@ -118,20 +117,20 @@
"karma-jasmine-html-reporter": "~2.2.0",
"karma-source-map-support": "1.4.0",
"lodash": "^4.17.21",
- "magic-string": "1.0.0",
+ "magic-string": "1.1.1",
"prettier": "^3.0.0",
- "puppeteer": "25.3.0",
+ "puppeteer": "25.6.0",
"quicktype-core": "26.0.0",
- "rollup": "4.62.2",
+ "rollup": "4.62.4",
"rollup-license-plugin": "~3.2.0",
- "rollup-plugin-dts": "6.4.1",
+ "rollup-plugin-dts": "6.5.1",
"rollup-plugin-sourcemaps2": "0.5.8",
"semver": "7.8.5",
"source-map-support": "0.5.21",
"tslib": "2.8.1",
- "undici": "8.7.0",
+ "undici": "8.10.0",
"unenv": "^1.10.0",
- "verdaccio": "6.8.0",
+ "verdaccio": "6.9.2",
"verdaccio-auth-memory": "^13.0.0",
"zone.js": "^0.16.0"
},
diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel
index 7325ec88d35b..75eedd6a7f5f 100644
--- a/packages/angular/build/BUILD.bazel
+++ b/packages/angular/build/BUILD.bazel
@@ -82,13 +82,13 @@ ts_project(
":node_modules/@angular-devkit/core",
":node_modules/@angular/ssr",
":node_modules/@babel/core",
- ":node_modules/@babel/helper-annotate-as-pure",
- ":node_modules/@babel/helper-split-export-declaration",
":node_modules/@inquirer/confirm",
":node_modules/@oxc-project/types",
+ ":node_modules/@parcel/watcher",
":node_modules/@vitejs/plugin-basic-ssl",
":node_modules/beasties",
":node_modules/browserslist",
+ ":node_modules/chokidar",
":node_modules/https-proxy-agent",
":node_modules/istanbul-lib-instrument",
":node_modules/jsonc-parser",
@@ -110,7 +110,7 @@ ts_project(
":node_modules/tinyglobby",
":node_modules/vite",
":node_modules/vitest",
- ":node_modules/watchpack",
+ ":node_modules/xxhash-wasm",
"//:node_modules/@angular/common",
"//:node_modules/@angular/compiler",
"//:node_modules/@angular/compiler-cli",
@@ -125,7 +125,6 @@ ts_project(
"//:node_modules/@types/node",
"//:node_modules/@types/picomatch",
"//:node_modules/@types/semver",
- "//:node_modules/@types/watchpack",
"//:node_modules/esbuild",
"//:node_modules/esbuild-wasm",
"//:node_modules/karma",
@@ -146,6 +145,7 @@ ts_project(
":build",
":node_modules/@angular-devkit/core",
":node_modules/@babel/core",
+ ":node_modules/oxc-parser",
"//:node_modules/@angular/compiler-cli",
"//:node_modules/@types/jasmine",
"//:node_modules/esbuild",
@@ -338,7 +338,7 @@ npm_package(
],
stamp_files = [
"src/utils/version.js",
- "src/tools/esbuild/utils.js",
+ "src/tools/esbuild/target.js",
"src/utils/normalize-cache.js",
"src/utils/supported-browsers.js",
],
diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json
index a049f92d4a73..0a3cc5cfad99 100644
--- a/packages/angular/build/package.json
+++ b/packages/angular/build/package.json
@@ -21,29 +21,29 @@
"@ampproject/remapping": "2.3.0",
"@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER",
"@babel/core": "8.0.1",
- "@babel/helper-annotate-as-pure": "8.0.0",
- "@babel/helper-split-export-declaration": "7.24.7",
"@inquirer/confirm": "6.1.1",
+ "@parcel/watcher": "2.6.0",
"@vitejs/plugin-basic-ssl": "2.3.0",
"beasties": "0.4.3",
"browserslist": "^4.26.0",
- "esbuild": "0.28.1",
+ "chokidar": "5.0.0",
+ "esbuild": "0.28.2",
"https-proxy-agent": "9.1.0",
"jsonc-parser": "3.3.1",
- "listr2": "10.2.2",
- "magic-string": "1.0.0",
+ "listr2": "11.0.0",
+ "magic-string": "1.1.1",
"mrmime": "2.0.1",
- "oxc-parser": "0.140.0",
+ "oxc-parser": "0.144.0",
"parse5-html-rewriting-stream": "8.0.1",
"picomatch": "4.0.5",
- "piscina": "5.2.0",
- "rolldown": "1.2.0",
- "sass": "1.101.0",
+ "piscina": "5.3.0",
+ "rolldown": "1.2.4",
+ "sass": "1.102.0",
"semver": "7.8.5",
"source-map-support": "0.5.21",
"tinyglobby": "0.2.17",
- "vite": "8.1.5",
- "watchpack": "2.5.2"
+ "vite": "8.2.1",
+ "xxhash-wasm": "1.1.0"
},
"optionalDependencies": {
"lmdb": "3.5.6"
@@ -51,13 +51,13 @@
"devDependencies": {
"@angular-devkit/core": "workspace:*",
"@angular/ssr": "workspace:*",
- "@oxc-project/types": "0.140.0",
+ "@oxc-project/types": "0.144.0",
"istanbul-lib-instrument": "6.0.3",
- "jsdom": "29.1.1",
- "less": "4.6.7",
- "ng-packagr": "22.1.0-next.3",
- "postcss": "8.5.19",
- "rollup": "4.62.2",
+ "jsdom": "30.0.1",
+ "less": "4.8.1",
+ "ng-packagr": "22.2.0-next.2",
+ "postcss": "8.5.26",
+ "rollup": "4.62.4",
"rxjs": "7.8.2",
"vitest": "4.1.10"
},
diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts
index d7dbdc8cfe21..e71663765274 100644
--- a/packages/angular/build/src/builders/application/build-action.ts
+++ b/packages/angular/build/src/builders/application/build-action.ts
@@ -15,6 +15,7 @@ import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-lan
import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils';
import { ChangedFiles } from '../../tools/esbuild/watcher';
import { shouldWatchRoot } from '../../utils/environment-options';
+import { initializeHash } from '../../utils/hash';
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
import { toPosixPath } from '../../utils/path';
import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options';
@@ -41,6 +42,7 @@ const packageWatchFiles = [
'.pnp.data.json',
];
+// eslint-disable-next-line max-lines-per-function
export async function* runEsBuildBuildAction(
action: (rebuildState?: RebuildState) => Promise,
options: {
@@ -78,10 +80,12 @@ export async function* runEsBuildBuildAction(
incrementalResults,
} = options;
+ await initializeHash();
+
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
// Initial build
- let result: ExecutionResult;
+ let result: ExecutionResult | undefined;
try {
// Perform the build action
result = await withProgress('Building...', () => action());
@@ -89,8 +93,8 @@ export async function* runEsBuildBuildAction(
// Log all diagnostic (error/warning/logs) messages
await logMessages(logger, result, colors, jsonLogs);
} finally {
- // Ensure Sass workers are shutdown if not watching
- if (!watch) {
+ // Ensure Sass workers are shutdown if not watching or if the initial build failed
+ if (!watch || !result) {
shutdownSassWorkerPool();
}
}
@@ -104,20 +108,25 @@ export async function* runEsBuildBuildAction(
logger.info('Watch mode enabled. Watching for file changes...');
}
+ const normalizedOutputBase = toPosixPath(outputOptions.base);
+ const normalizedCacheBase = toPosixPath(cacheOptions.basePath);
const ignored: string[] = [
// Ignore the output and cache paths to avoid infinite rebuild cycles
- outputOptions.base,
- cacheOptions.basePath,
+ normalizedOutputBase,
+ `${normalizedOutputBase}/**`,
+ normalizedCacheBase,
+ `${normalizedCacheBase}/**`,
`${toPosixPath(workspaceRoot)}/**/.*/**`,
];
// Setup a watcher
const { createWatcher } = await import('../../tools/esbuild/watcher');
- watcher = createWatcher({
+ watcher = await createWatcher({
polling: typeof poll === 'number',
interval: poll,
followSymlinks: preserveSymlinks,
ignored,
+ cwd: workspaceRoot,
});
// Setup abort support
@@ -215,6 +224,9 @@ export async function* runEsBuildBuildAction(
// Remove any stale locations if the build was successful
if (staleWatchFiles?.size) {
watcher.remove([...staleWatchFiles]);
+ for (const staleFile of staleWatchFiles) {
+ currentWatchFiles.delete(staleFile);
+ }
}
for (const outputResult of emitOutputResults(
diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts
index 7d7a6e56b829..2241a4204999 100644
--- a/packages/angular/build/src/builders/application/chunk-optimizer.ts
+++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts
@@ -418,6 +418,7 @@ export async function optimizeChunks(
const record = createInitialFileRecord(entryRecord.depth + 1);
+ original.initialFiles.set(importPath, record);
entriesToAnalyze.push([importPath, record]);
}
}
diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts
index 21beb57bf478..53aaec882cbf 100644
--- a/packages/angular/build/src/builders/application/execute-build.ts
+++ b/packages/angular/build/src/builders/application/execute-build.ts
@@ -18,11 +18,8 @@ import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin';
import { extractLicenses } from '../../tools/esbuild/license-extractor';
import { profileAsync } from '../../tools/esbuild/profiling';
-import {
- calculateEstimatedTransferSizes,
- logBuildStats,
- transformSupportedBrowsersToTargets,
-} from '../../tools/esbuild/utils';
+import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target';
+import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbuild/utils';
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
import { optimizeChunksThreshold } from '../../utils/environment-options';
import { resolveAssets } from '../../utils/resolve-assets';
@@ -74,12 +71,19 @@ export async function executeBuild(
let bundlingResult: BundleContextResult;
let templateUpdates: Map | undefined;
let angularCompilationContext: AngularCompilationContext | undefined;
+ let executionResult: ExecutionResult | undefined;
try {
if (rebuildState) {
bundlerContexts = rebuildState.rebuildContexts;
componentStyleBundler = rebuildState.componentStyleBundler;
codeBundleCache = rebuildState.codeBundleCache;
templateUpdates = rebuildState.templateUpdates;
+ executionResult = new ExecutionResult(
+ bundlerContexts,
+ componentStyleBundler,
+ codeBundleCache,
+ templateUpdates,
+ );
// Reset template updates for new rebuild
templateUpdates?.clear();
@@ -122,6 +126,12 @@ export async function executeBuild(
angularCompilationContext,
templateUpdates,
);
+ executionResult = new ExecutionResult(
+ bundlerContexts,
+ componentStyleBundler,
+ codeBundleCache,
+ templateUpdates,
+ );
// Bundle everything on initial build
bundlingResult = await BundlerContext.bundleAll([
@@ -138,234 +148,241 @@ export async function executeBuild(
const componentResults = await componentStyleBundler.bundleAllFiles(true, true);
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]);
}
- } catch (error) {
- await angularCompilationContext?.dispose();
- throw error;
- }
-
- const executionResult = new ExecutionResult(
- bundlerContexts,
- componentStyleBundler,
- codeBundleCache,
- templateUpdates,
- );
- executionResult.addWarnings(bundlingResult.warnings);
-
- // Add used external component style referenced files to be watched
- if (options.externalRuntimeStyles) {
- executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles());
- }
- // Return if the bundling has errors
- if (bundlingResult.errors) {
- executionResult.addErrors(bundlingResult.errors);
-
- return executionResult;
- }
+ executionResult.addWarnings(bundlingResult.warnings);
- // Optimize chunks if enabled and threshold is met.
- // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild.
- if (options.optimizationOptions.scripts) {
- // Count lazy chunks (files not needed for initial load).
- // Advanced chunk optimization is most beneficial when there are multiple lazy chunks.
- const { metafile, initialFiles } = bundlingResult;
- const lazyChunksCount = Object.keys(metafile.outputs).filter(
- (path) => path.endsWith('.js') && !initialFiles.has(path),
- ).length;
-
- // Only run if the number of lazy chunks meets the configured threshold.
- // This avoids overhead for small projects with few chunks.
- if (lazyChunksCount >= optimizeChunksThreshold) {
- const { optimizeChunks } = await import('./chunk-optimizer');
- const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
- optimizeChunks(
- bundlingResult,
- options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
- ),
- );
+ // Add used external component style referenced files to be watched
+ if (options.externalRuntimeStyles) {
+ executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles());
+ }
- if (optimizationResult.errors) {
- executionResult.addErrors(optimizationResult.errors);
+ // In non-watch mode, the bundler rebuild contexts and Angular compilation
+ // are no longer needed once bundling completes. Dispose them early to free
+ // worker processes, TypeScript ASTs, and caches before post-bundling steps
+ // execute or before returning bundling errors.
+ // This is fire-and-forget so worker teardown runs concurrently in the background
+ // without blocking the critical path of post-bundle optimization steps.
+ // Any in-flight disposal is awaited in the builder action's finally block.
+ if (!options.watch) {
+ void angularCompilationContext?.dispose();
+ void executionResult.dispose();
+ }
- return executionResult;
- }
+ // Return if the bundling has errors
+ if (bundlingResult.errors) {
+ executionResult.addErrors(bundlingResult.errors);
- bundlingResult = optimizationResult;
+ return executionResult;
}
- }
- // Analyze external imports if external options are enabled
- if (options.externalPackages || bundlingResult.externalConfiguration) {
- const {
- externalConfiguration = [],
- externalImports: { browser = [], server = [] },
- } = bundlingResult;
- // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
- // which matches import paths like @foo/bar/baz.
- // This means all paths within the @foo/bar package are also marked as external.
- const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/');
- const exclusions = new Set(externalConfiguration);
- const explicitExternal = new Set();
-
- const isExplicitExternal = (dep: string): boolean => {
- // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable
- // even when the containing package is externalized by the user.
- if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) {
- return false;
- }
+ // Optimize chunks if enabled and threshold is met.
+ // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild.
+ if (options.optimizationOptions.scripts) {
+ // Count lazy chunks (files not needed for initial load).
+ // Advanced chunk optimization is most beneficial when there are multiple lazy chunks.
+ const { metafile, initialFiles } = bundlingResult;
+ const lazyChunksCount = Object.keys(metafile.outputs).filter(
+ (path) => path.endsWith('.js') && !initialFiles.has(path),
+ ).length;
+
+ // Only run if the number of lazy chunks meets the configured threshold.
+ // This avoids overhead for small projects with few chunks.
+ if (lazyChunksCount >= optimizeChunksThreshold) {
+ const { optimizeChunks } = await import('./chunk-optimizer');
+ const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
+ optimizeChunks(
+ bundlingResult,
+ options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
+ ),
+ );
+
+ if (optimizationResult.errors) {
+ executionResult.addErrors(optimizationResult.errors);
+
+ return executionResult;
+ }
- if (exclusions.has(dep)) {
- return true;
+ bundlingResult = optimizationResult;
}
+ }
- for (const prefix of exclusionsPrefixes) {
- if (dep.startsWith(prefix)) {
+ // Analyze external imports if external options are enabled
+ if (options.externalPackages || bundlingResult.externalConfiguration) {
+ const {
+ externalConfiguration = [],
+ externalImports: { browser = [], server = [] },
+ } = bundlingResult;
+ // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
+ // which matches import paths like @foo/bar/baz.
+ // This means all paths within the @foo/bar package are also marked as external.
+ const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/');
+ const exclusions = new Set(externalConfiguration);
+ const explicitExternal = new Set();
+
+ const isExplicitExternal = (dep: string): boolean => {
+ // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable
+ // even when the containing package is externalized by the user.
+ if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) {
+ return false;
+ }
+
+ if (exclusions.has(dep)) {
return true;
}
- }
- return false;
- };
+ for (const prefix of exclusionsPrefixes) {
+ if (dep.startsWith(prefix)) {
+ return true;
+ }
+ }
- const implicitBrowser: string[] = [];
- for (const dep of browser) {
- if (isExplicitExternal(dep)) {
- explicitExternal.add(dep);
- } else {
- implicitBrowser.push(dep);
+ return false;
+ };
+
+ const implicitBrowser: string[] = [];
+ for (const dep of browser) {
+ if (isExplicitExternal(dep)) {
+ explicitExternal.add(dep);
+ } else {
+ implicitBrowser.push(dep);
+ }
}
- }
- const implicitServer: string[] = [];
- for (const dep of server) {
- if (isExplicitExternal(dep)) {
- explicitExternal.add(dep);
- } else {
- implicitServer.push(dep);
+ const implicitServer: string[] = [];
+ for (const dep of server) {
+ if (isExplicitExternal(dep)) {
+ explicitExternal.add(dep);
+ } else {
+ implicitServer.push(dep);
+ }
}
- }
- executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
- }
+ executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
+ }
- const { metafile, initialFiles, outputFiles } = bundlingResult;
+ const { metafile, initialFiles, outputFiles } = bundlingResult;
- executionResult.outputFiles.push(...outputFiles);
+ executionResult.outputFiles.push(...outputFiles);
- // Analyze files for bundle budget failures if present
- let budgetFailures: BudgetCalculatorResult[] | undefined;
- if (options.budgets) {
- const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles);
- budgetFailures = [...checkBudgets(options.budgets, compatStats, true)];
- for (const { message, severity } of budgetFailures) {
- if (severity === 'error') {
- executionResult.addError(message);
- } else {
- executionResult.addWarning(message);
+ // Analyze files for bundle budget failures if present
+ let budgetFailures: BudgetCalculatorResult[] | undefined;
+ if (options.budgets) {
+ const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles);
+ budgetFailures = [...checkBudgets(options.budgets, compatStats, true)];
+ for (const { message, severity } of budgetFailures) {
+ if (severity === 'error') {
+ executionResult.addError(message);
+ } else {
+ executionResult.addWarning(message);
+ }
}
}
- }
- // Calculate estimated transfer size if scripts are optimized
- let estimatedTransferSizes;
- if (optimizationOptions.scripts || optimizationOptions.styles.minify) {
- estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles);
- }
-
- // Check metafile for CommonJS module usage if optimizing scripts
- if (optimizationOptions.scripts) {
- const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies);
- executionResult.addWarnings(messages);
- }
+ // Calculate estimated transfer size if scripts are optimized
+ let estimatedTransferSizes;
+ if (optimizationOptions.scripts || optimizationOptions.styles.minify) {
+ estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles);
+ }
- // Copy assets
- if (assets) {
- executionResult.addAssets(await resolveAssets(assets, workspaceRoot));
- }
+ // Check metafile for CommonJS module usage if optimizing scripts
+ if (optimizationOptions.scripts) {
+ const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies);
+ executionResult.addWarnings(messages);
+ }
- // Extract and write licenses for used packages
- if (options.extractLicenses) {
- executionResult.addOutputFile(
- '3rdpartylicenses.txt',
- await extractLicenses(metafile, workspaceRoot),
- BuildOutputFileType.Root,
- );
- }
+ // Copy assets
+ if (assets) {
+ executionResult.addAssets(await resolveAssets(assets, workspaceRoot));
+ }
- // Watch input index HTML file if configured
- if (options.indexHtmlOptions) {
- executionResult.extraWatchFiles.push(options.indexHtmlOptions.input);
- executionResult.htmlIndexPath = options.indexHtmlOptions.output;
- executionResult.htmlBaseHref = options.baseHref;
- }
+ // Extract and write licenses for used packages
+ if (options.extractLicenses) {
+ executionResult.addOutputFile(
+ '3rdpartylicenses.txt',
+ await extractLicenses(metafile, workspaceRoot),
+ BuildOutputFileType.Root,
+ );
+ }
- // Create server app engine manifest
- if (serverEntryPoint) {
- executionResult.addOutputFile(
- SERVER_APP_ENGINE_MANIFEST_FILENAME,
- generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref),
- BuildOutputFileType.ServerRoot,
- );
- }
+ // Watch input index HTML file if configured
+ if (options.indexHtmlOptions) {
+ executionResult.extraWatchFiles.push(options.indexHtmlOptions.input);
+ executionResult.htmlIndexPath = options.indexHtmlOptions.output;
+ executionResult.htmlBaseHref = options.baseHref;
+ }
- // Perform i18n translation inlining if enabled
- if (i18nOptions.shouldInline) {
- const result = await inlineI18n(metafile, options, executionResult, initialFiles);
- executionResult.addErrors(result.errors);
- executionResult.addWarnings(result.warnings);
- executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
- } else {
- const result = await executePostBundleSteps(
- metafile,
- options,
- executionResult.outputFiles,
- executionResult.assetFiles,
- initialFiles,
- // Set lang attribute to the defined source locale if present
- i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined,
- );
+ // Create server app engine manifest
+ if (serverEntryPoint) {
+ executionResult.addOutputFile(
+ SERVER_APP_ENGINE_MANIFEST_FILENAME,
+ generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref),
+ BuildOutputFileType.ServerRoot,
+ );
+ }
- // Deduplicate and add errors and warnings
- executionResult.addErrors([...new Set(result.errors)]);
- executionResult.addWarnings([...new Set(result.warnings)]);
+ // Perform i18n translation inlining if enabled
+ if (i18nOptions.shouldInline) {
+ const result = await inlineI18n(metafile, options, executionResult, initialFiles);
+ executionResult.addErrors(result.errors);
+ executionResult.addWarnings(result.warnings);
+ executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
+ } else {
+ const result = await executePostBundleSteps(
+ metafile,
+ options,
+ executionResult.outputFiles,
+ executionResult.assetFiles,
+ initialFiles,
+ // Set lang attribute to the defined source locale if present
+ i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined,
+ );
- executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
- executionResult.outputFiles.push(...result.additionalOutputFiles);
- executionResult.assetFiles.push(...result.additionalAssets);
- }
+ // Deduplicate and add errors and warnings
+ executionResult.addErrors([...new Set(result.errors)]);
+ executionResult.addWarnings([...new Set(result.warnings)]);
- executionResult.addOutputFile(
- 'prerendered-routes.json',
- JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2),
- BuildOutputFileType.Root,
- );
+ executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
+ executionResult.outputFiles.push(...result.additionalOutputFiles);
+ executionResult.assetFiles.push(...result.additionalAssets);
+ }
- // Write metafile if stats option is enabled
- if (options.stats) {
executionResult.addOutputFile(
- 'stats.json',
- JSON.stringify(metafile, null, 2),
+ 'prerendered-routes.json',
+ JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2),
BuildOutputFileType.Root,
);
- }
- if (!jsonLogs && !options.quiet) {
- const changedFiles =
- rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo);
- executionResult.addLog(
- logBuildStats(
- metafile,
- outputFiles,
- initialFiles,
- budgetFailures,
- colors,
- changedFiles,
- estimatedTransferSizes,
- !!ssrOptions,
- verbose,
- ),
- );
- }
+ // Write metafile if stats option is enabled
+ if (options.stats) {
+ executionResult.addOutputFile(
+ 'stats.json',
+ JSON.stringify(metafile, null, 2),
+ BuildOutputFileType.Root,
+ );
+ }
+
+ if (!jsonLogs && !options.quiet) {
+ const changedFiles =
+ rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo);
+ executionResult.addLog(
+ logBuildStats(
+ metafile,
+ outputFiles,
+ initialFiles,
+ budgetFailures,
+ colors,
+ changedFiles,
+ estimatedTransferSizes,
+ !!ssrOptions,
+ verbose,
+ ),
+ );
+ }
- return executionResult;
+ return executionResult;
+ } catch (error) {
+ await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]);
+
+ throw error;
+ }
}
diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts
index c83f1a29a30a..137336497885 100644
--- a/packages/angular/build/src/builders/application/i18n.ts
+++ b/packages/angular/build/src/builders/application/i18n.ts
@@ -8,6 +8,7 @@
import { BuilderContext } from '@angular-devkit/architect';
import type { Metafile } from 'esbuild';
+import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
ExecutionResult,
@@ -18,6 +19,7 @@ import { I18nInliner } from '../../tools/esbuild/i18n-inliner';
import { maxWorkers } from '../../utils/environment-options';
import { loadTranslations } from '../../utils/i18n-options';
import { createTranslationLoader } from '../../utils/load-translations';
+import { createProjectResolver } from '../../utils/resolve-project';
import { executePostBundleSteps } from './execute-post-bundle';
import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options';
@@ -48,6 +50,7 @@ export async function inlineI18n(
outputFiles: executionResult.outputFiles,
shouldOptimize: optimizationOptions.scripts,
persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined,
+ localizeVersion: i18nOptions.localizeVersion,
},
maxWorkers,
);
@@ -72,10 +75,21 @@ export async function inlineI18n(
try {
for (const locale of i18nOptions.inlineLocales) {
+ const localeDescription = i18nOptions.locales[locale];
+ let translationIntegrity: string | undefined = '';
+ for (const file of localeDescription.files) {
+ if (!file.integrity) {
+ translationIntegrity = undefined;
+ break;
+ }
+ translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity;
+ }
+
// A locale specific set of files is returned from the inliner.
const localeInlineResult = await inliner.inlineForLocale(
locale,
- i18nOptions.locales[locale].translation,
+ localeDescription.translation,
+ translationIntegrity,
);
const localeOutputFiles = localeInlineResult.outputFiles;
inlineResult.errors.push(...localeInlineResult.errors);
@@ -176,6 +190,15 @@ export async function loadActiveTranslations(
context: BuilderContext,
i18n: NormalizedApplicationBuildOptions['i18nOptions'],
) {
+ if (!i18n.localizeVersion) {
+ try {
+ const projectResolve = createProjectResolver(context.workspaceRoot);
+ const manifestPath = projectResolve('@angular/localize/package.json');
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string };
+ i18n.localizeVersion = manifest.version;
+ } catch {}
+ }
+
// Load locale data and translations (if present)
let loader;
for (const [locale, desc] of Object.entries(i18n.locales)) {
diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts
index 4ec810ff3c07..abe3298aa093 100644
--- a/packages/angular/build/src/builders/application/options.ts
+++ b/packages/angular/build/src/builders/application/options.ts
@@ -126,6 +126,20 @@ interface InternalOptions {
* Suppress build summary and stats table.
*/
quiet?: boolean;
+
+ /**
+ * Disables esbuild code splitting for the browser code bundle.
+ *
+ * Splitting emits shared chunks whose exports are read across chunk boundaries as live ESM
+ * bindings. A module hoisted into a shared chunk is wrapped in a lazy initializer, so its exported
+ * value is only assigned once that initializer runs. Runners that load the generated output
+ * through a module runner rather than the browser's own ESM implementation do not reliably
+ * preserve those bindings, and an importing chunk can observe the export as `undefined`.
+ *
+ * Test bundles are never downloaded by a browser, so there is nothing for splitting to optimize
+ * there. Used exclusively for tests and shouldn't be used for other kinds of builds.
+ */
+ disableCodeSplitting?: boolean;
}
/** Full set of options for `application` builder. */
@@ -160,7 +174,7 @@ export async function normalizeOptions(
options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks');
// Setup base paths based on workspace root and project information
- const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks);
+ const workspaceRoot = canonicalizePath(context.workspaceRoot);
const projectMetadata = await context.getProjectMetadata(projectName);
const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata);
@@ -439,6 +453,7 @@ export async function normalizeOptions(
partialSSRBuild = false,
externalRuntimeStyles,
instrumentForCoverage,
+ disableCodeSplitting,
} = options;
// Return all the normalized options
@@ -475,6 +490,7 @@ export async function normalizeOptions(
watch,
workspaceRoot,
entryPoints,
+ disableCodeSplitting,
optimizationOptions,
outputOptions,
outExtension,
diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts
index bb53c0bae902..79209ba0eaf9 100644
--- a/packages/angular/build/src/builders/application/setup-bundling.ts
+++ b/packages/angular/build/src/builders/application/setup-bundling.ts
@@ -19,7 +19,7 @@ import {
import { BundlerContext } from '../../tools/esbuild/bundler-context';
import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts';
import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles';
-import { getSupportedNodeTargets } from '../../tools/esbuild/utils';
+import { getSupportedNodeTargets } from '../../tools/esbuild/target';
import type { NormalizedApplicationBuildOptions } from './options';
/**
diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
index 22c4c32202bd..2de5c5f229ca 100644
--- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
@@ -132,5 +132,119 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
{ outputLogsOnFailure: false },
);
});
+
+ it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ styles: ['src/styles.css'],
+ });
+
+ await harness.writeFile(
+ 'test-plugin.js',
+ `
+ const fs = require('fs');
+ const path = require('path');
+ module.exports = () => {
+ return {
+ postcssPlugin: 'test-plugin',
+ Once(root, { result }) {
+ const themePath = path.join(path.dirname(root.source.input.file), 'theme.json');
+ result.messages.push({
+ type: 'dependency',
+ file: themePath,
+ });
+ const data = fs.readFileSync(themePath, 'utf-8');
+ const json = JSON.parse(data);
+ root.append('body { color: ' + json.color + '; }');
+ },
+ };
+ };
+ module.exports.postcss = true;
+ `,
+ );
+ await harness.writeFile(
+ '.postcssrc.json',
+ JSON.stringify({
+ plugins: {
+ './test-plugin.js': {},
+ },
+ }),
+ );
+ await harness.writeFile('src/styles.css', '/* base */');
+ await harness.writeFile('src/theme.json', '{"color": "aqua"}');
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');
+
+ await harness.writeFile('src/theme.json', 'invalid-json');
+ },
+ async ({ result }) => {
+ expect(result?.success).toBe(false);
+
+ await harness.writeFile('src/theme.json', '{"color": "blue"}');
+ },
+ ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
+
+ it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ styles: ['src/styles.css'],
+ });
+
+ await harness.writeFile(
+ 'noop-plugin.js',
+ `
+ module.exports = () => ({ postcssPlugin: 'noop-plugin' });
+ module.exports.postcss = true;
+ `,
+ );
+ await harness.writeFile(
+ '.postcssrc.json',
+ JSON.stringify({
+ plugins: {
+ './noop-plugin.js': {},
+ },
+ }),
+ );
+ await harness.writeFile('src/styles.css', "@import './a.css';");
+ await harness.writeFile('src/a.css', "a { ' }");
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBe(false);
+
+ await harness.writeFile('src/a.css', 'body { color: aqua; }');
+ },
+ async ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');
+
+ await harness.writeFile('src/a.css', 'body { color: blue; }');
+ },
+ ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
});
});
diff --git a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
index 402200a27f9d..2c495e703a7b 100644
--- a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
@@ -55,5 +55,96 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT');
harness.expectFile('dist/browser/en-US/main.js').toExist();
});
+
+ it(`should extract license from a package with a lowercase 'license' file`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-a/package.json',
+ JSON.stringify({
+ name: 'test-package-a',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'MIT',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-a/index.js',
+ 'console.log("test-package-a");',
+ );
+ await harness.writeFile('node_modules/test-package-a/license', 'TEST_LOWERCASE_LICENSE_TEXT');
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-a';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness
+ .expectFile('dist/3rdpartylicenses.txt')
+ .content.toContain('TEST_LOWERCASE_LICENSE_TEXT');
+ });
+
+ it(`should extract license from a package with an alternative license file name (e.g., 'MIT-LICENCE.txt')`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-b/package.json',
+ JSON.stringify({
+ name: 'test-package-b',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'MIT',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-b/index.js',
+ 'console.log("test-package-b");',
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-b/MIT-LICENCE.txt',
+ 'TEST_ALTERNATIVE_LICENSE_TEXT',
+ );
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-b';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness
+ .expectFile('dist/3rdpartylicenses.txt')
+ .content.toContain('TEST_ALTERNATIVE_LICENSE_TEXT');
+ });
+
+ it(`should extract license from a package with a custom license file specified in package.json`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-c/package.json',
+ JSON.stringify({
+ name: 'test-package-c',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'SEE LICENSE IN custom-license.md',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-c/index.js',
+ 'console.log("test-package-c");',
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-c/custom-license.md',
+ 'TEST_CUSTOM_LICENSE_TEXT',
+ );
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-c';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('TEST_CUSTOM_LICENSE_TEXT');
+ });
});
});
diff --git a/packages/angular/build/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts
index 11228658bbce..b53467caaec6 100644
--- a/packages/angular/build/src/builders/application/tests/options/index_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts
@@ -79,16 +79,17 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/index.html').content.toContain('TEST_123');
});
- // TODO: Build needs to be fixed to not throw an unhandled exception for this case
- xit('should fail build when a string path to non-existent file', async () => {
+ it('should fail build and terminate cleanly when a string path to non-existent file is provided', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
index: 'src/not-here.html',
});
- const { result } = await harness.executeOnce({ outputLogsOnFailure: false });
+ const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
- expect(result?.success).toBe(false);
+ expect(result).toBeUndefined();
+ expect(error).toEqual(jasmine.any(Error));
+ expect(error?.message).toMatch(/Failed to read index HTML file/i);
harness.expectFile('dist/browser/index.html').toNotExist();
});
diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
index e846053e64c4..ca9212b0d3e3 100644
--- a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
@@ -238,5 +238,21 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
.withContext('importmap must precede the first module script tag')
.toBeLessThan(moduleScriptIdx);
});
+
+ it(`preserves integrity and crossorigin in autoCsp loader when autoCsp is also enabled`, async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ subresourceIntegrity: true,
+ security: { autoCsp: true },
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ const indexHtml = harness.readFile('dist/browser/index.html');
+ expect(indexHtml).toMatch(
+ /const scripts = \[\[(?:'[^']+', 'module', false, false, "sha384-[^"]+", "anonymous"(?:, )?)+\]\];/,
+ );
+ });
});
});
diff --git a/packages/angular/build/src/builders/dev-server/internal.ts b/packages/angular/build/src/builders/dev-server/internal.ts
index 4e5e97e5cf14..96587acb5cff 100644
--- a/packages/angular/build/src/builders/dev-server/internal.ts
+++ b/packages/angular/build/src/builders/dev-server/internal.ts
@@ -13,7 +13,7 @@ export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils';
export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator';
export { purgeStaleBuildCache } from '../../utils/purge-cache';
export { getSupportedBrowsers } from '../../utils/supported-browsers';
-export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/utils';
+export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target';
export { buildApplicationInternal } from '../../builders/application';
export type { ApplicationBuilderInternalOptions } from '../../builders/application/options';
export type { ExternalResultMetadata } from '../../tools/esbuild/bundler-execution-result';
diff --git a/packages/angular/build/src/builders/dev-server/vite/server.ts b/packages/angular/build/src/builders/dev-server/vite/server.ts
index c616e865a68b..b1826383fccb 100644
--- a/packages/angular/build/src/builders/dev-server/vite/server.ts
+++ b/packages/angular/build/src/builders/dev-server/vite/server.ts
@@ -22,6 +22,7 @@ import {
} from '../../../tools/vite/plugins';
import { RolldownLoaderOption, getDepOptimizationConfig } from '../../../tools/vite/utils';
import { loadProxyConfiguration } from '../../../utils';
+import { initializeHash } from '../../../utils/hash';
import { type ApplicationBuilderInternalOptions, JavaScriptTransformer } from '../internal';
import type { NormalizedDevServerOptions } from '../options';
import { DevServerExternalResultMetadata, OutputAssetRecord, OutputFileRecord } from './utils';
@@ -104,6 +105,7 @@ function createSsrConfig(
prebundleLoaderExtensions: RolldownLoaderOption | undefined,
thirdPartySourcemaps: boolean,
define: ApplicationBuilderInternalOptions['define'],
+ target: string[],
): Vite.SSROptions {
return {
// Note: `true` and `/.*/` have different sematics. When true, the `external` option is ignored.
@@ -111,6 +113,7 @@ function createSsrConfig(
// Exclude any Node.js built in module and provided dependencies (currently build defined externals)
external: externalMetadata.explicitServer,
optimizeDeps: getDepOptimizationConfig({
+ target,
// Only enable with caching since it causes prebundle dependencies to be cached
disabled: serverOptions.prebundle === false,
// Exclude any explicitly defined dependencies (currently build defined externals and node.js built-ins)
@@ -145,6 +148,7 @@ export async function setupServer(
indexHtmlTransformer?: (content: string) => Promise,
thirdPartySourcemaps = false,
): Promise {
+ await initializeHash();
const { normalizePath } = (await import('vite' as string)) as typeof Vite;
// Path will not exist on disk and only used to provide separate path for Vite requests
@@ -214,6 +218,7 @@ export async function setupServer(
prebundleLoaderExtensions,
thirdPartySourcemaps,
define,
+ target,
),
plugins: [
createAngularSetupMiddlewaresPlugin({
@@ -239,6 +244,7 @@ export async function setupServer(
],
// Browser only optimizeDeps. (This does not run for SSR dependencies).
optimizeDeps: getDepOptimizationConfig({
+ target,
// Only enable with caching since it causes prebundle dependencies to be cached
disabled: serverOptions.prebundle === false,
// Exclude any explicitly defined dependencies (currently build defined externals)
diff --git a/packages/angular/build/src/builders/karma/application_builder.ts b/packages/angular/build/src/builders/karma/application_builder.ts
index b9a28e1bf0b0..d0adb6c3cf0e 100644
--- a/packages/angular/build/src/builders/karma/application_builder.ts
+++ b/packages/angular/build/src/builders/karma/application_builder.ts
@@ -11,7 +11,6 @@ import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from
import { randomUUID } from 'node:crypto';
import { rmSync } from 'node:fs';
import * as fs from 'node:fs/promises';
-import { createRequire } from 'node:module';
import path from 'node:path';
import { ReadableStream } from 'node:stream/web';
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
@@ -66,8 +65,14 @@ export function execute(
init = await initializeApplication(normalizedOptions, context, karmaOptions, transforms);
} catch (err) {
if (err instanceof ApplicationBuildError) {
- controller.enqueue({ success: false, message: err.message });
- controller.close();
+ if (controller.desiredSize !== null) {
+ try {
+ controller.enqueue({ success: false, message: err.message });
+ controller.close();
+ } catch {
+ // Stream controller may already be closed or cancelled
+ }
+ }
return;
}
@@ -85,8 +90,14 @@ export function execute(
// Close the stream once the Karma server returns.
karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
- controller.enqueue({ success: exitCode === 0 });
- controller.close();
+ if (controller.desiredSize !== null) {
+ try {
+ controller.enqueue({ success: exitCode === 0 });
+ controller.close();
+ } catch {
+ // Stream controller may already be closed or cancelled
+ }
+ }
});
await karmaServer.start();
diff --git a/packages/angular/build/src/builders/karma/progress-reporter.ts b/packages/angular/build/src/builders/karma/progress-reporter.ts
index 16824badd095..6fc92b6c74c8 100644
--- a/packages/angular/build/src/builders/karma/progress-reporter.ts
+++ b/packages/angular/build/src/builders/karma/progress-reporter.ts
@@ -59,8 +59,18 @@ export function injectKarmaReporter(
break;
}
+ if (controller.desiredSize === null) {
+ break;
+ }
+
if (buildOutput.kind === ResultKind.Failure) {
- controller.enqueue({ success: false, message: 'Build failed' });
+ if (controller.desiredSize !== null) {
+ try {
+ controller.enqueue({ success: false, message: 'Build failed' });
+ } catch {
+ // Stream controller may already be closed or cancelled
+ }
+ }
} else if (
buildOutput.kind === ResultKind.Incremental ||
buildOutput.kind === ResultKind.Full
@@ -81,10 +91,12 @@ export function injectKarmaReporter(
}
onRunComplete = function (_browsers: unknown, results: RunCompleteInfo): void {
- if (results.exitCode === 0) {
- controller.enqueue({ success: true });
- } else {
- controller.enqueue({ success: false });
+ if (controller.desiredSize !== null) {
+ try {
+ controller.enqueue({ success: results.exitCode === 0 });
+ } catch {
+ // Stream controller may already be closed or cancelled
+ }
}
};
}
diff --git a/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts
index 366cc2aa20a1..a69698b8d743 100644
--- a/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts
+++ b/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts
@@ -28,5 +28,18 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
expect(result?.success).toBeFalse();
});
+
+ it('handles stream cancellation gracefully in watch mode', async () => {
+ harness.useTarget('test', {
+ ...BASE_OPTIONS,
+ watch: true,
+ });
+
+ const { result } = await harness.executeOnce({
+ outputLogsOnFailure: false,
+ });
+
+ expect(result?.success).toBeTrue();
+ });
});
});
diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts
index d3402b090e0f..7d57716214b1 100644
--- a/packages/angular/build/src/builders/unit-test/options.ts
+++ b/packages/angular/build/src/builders/unit-test/options.ts
@@ -10,7 +10,7 @@ import { type BuilderContext, targetFromTargetString } from '@angular-devkit/arc
import { constants, promises as fs } from 'node:fs';
import path from 'node:path';
import { normalizeCacheOptions } from '../../utils/normalize-cache';
-import { canonicalizePath } from '../../utils/path';
+import { canonicalizePath, toPosixPath } from '../../utils/path';
import { getProjectRootPaths } from '../../utils/project-metadata';
import { isTTY } from '../../utils/tty';
import { Runner, type Schema as UnitTestBuilderOptions } from './schema';
@@ -58,7 +58,7 @@ export async function normalizeOptions(
: process.execArgv.includes('--preserve-symlinks');
// Setup base paths based on workspace root and project information
- const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks);
+ const workspaceRoot = canonicalizePath(context.workspaceRoot);
const projectMetadata = await context.getProjectMetadata(projectName);
const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata);
@@ -143,7 +143,7 @@ export async function normalizeOptions(
quiet: options.quiet ?? (process.env['CI'] ? false : true),
providersFile: options.providersFile && path.join(workspaceRoot, options.providersFile),
setupFiles: options.setupFiles
- ? options.setupFiles.map((setupFile) => path.join(workspaceRoot, setupFile))
+ ? options.setupFiles.map((setupFile) => toPosixPath(path.join(workspaceRoot, setupFile)))
: [],
dumpVirtualFiles: options.dumpVirtualFiles,
listTests: options.listTests,
diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts
index 7f0f67fc0c2e..3936f44b09fd 100644
--- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts
+++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts
@@ -257,6 +257,12 @@ export async function getVitestBuildOptions(
outputHashing: adjustOutputHashing(baseBuildOptions.outputHashing),
optimization: false,
entryPoints,
+ // Every spec file is its own entry point, so splitting hoists any module shared between two
+ // specs into a chunk whose exports are then read across a chunk boundary. Those reads rely on
+ // live ESM bindings, and a module placed in a shared chunk is only assigned its exported value
+ // when that chunk's lazy initializer runs, so an importing chunk can read `undefined`. Nothing
+ // downloads these bundles, so there is no benefit to weigh against that.
+ disableCodeSplitting: true,
// Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig
// and the `optimizeDeps.exclude` option.
externalPackages: true,
diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts
index 7bad7079dc90..f2fc8c221646 100644
--- a/packages/angular/build/src/builders/unit-test/test-discovery.ts
+++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts
@@ -6,11 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { createHash } from 'node:crypto';
import { type PathLike, constants, promises as fs } from 'node:fs';
import os from 'node:os';
import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
import { glob, isDynamicPattern } from 'tinyglobby';
+import { calculateHash, initializeHash } from '../../utils/hash';
import { toPosixPath } from '../../utils/path';
/**
@@ -41,6 +41,7 @@ export async function findTests(
workspaceRoot: string,
projectSourceRoot: string,
): Promise {
+ await initializeHash();
const resolvedTestFiles = new Set();
const dynamicPatterns: string[] = [];
@@ -194,7 +195,7 @@ function truncateName(name: string, originalPath: string): string {
return name;
}
- const hash = createHash('sha256').update(originalPath).digest('hex').substring(0, 8);
+ const hash = calculateHash(originalPath).substring(0, 8);
const availableLength = MAX_FILENAME_LENGTH - hash.length - 2; // 2 for '-' separators
const prefixLength = Math.floor(availableLength / 2);
const suffixLength = availableLength - prefixLength;
diff --git a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts
index dcee1718a976..764924d9552b 100644
--- a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts
+++ b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts
@@ -6,9 +6,14 @@
* found in the LICENSE file at https://angular.dev/license
*/
+import { initializeHash } from '../../utils/hash';
import { generateNameFromPath, getTestEntrypoints } from './test-discovery';
describe('getTestEntrypoints', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
const workspaceRoot = '/project';
const projectSourceRoot = '/project/src';
const options = { workspaceRoot, projectSourceRoot };
@@ -81,6 +86,10 @@ describe('getTestEntrypoints', () => {
});
describe('generateNameFromPath', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
const roots = ['/project/src/', '/project/'];
it('should generate a dash-cased name from a simple path', () => {
@@ -127,7 +136,7 @@ describe('generateNameFromPath', () => {
expect(result.length).toBeLessThanOrEqual(128);
expect(result).toBe(
- 'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-9cf40291-me-in-order-to-trigger-the-truncation-logic-in-the-function',
+ 'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-4af8113d-me-in-order-to-trigger-the-truncation-logic-in-the-function',
); // eslint-disable-line max-len
});
diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts
new file mode 100644
index 000000000000..13870ba36eba
--- /dev/null
+++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { execute } from '../../index';
+import {
+ BASE_OPTIONS,
+ describeBuilder,
+ UNIT_TEST_BUILDER_INFO,
+ setupApplicationTarget,
+} from '../setup';
+
+describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
+ describe('Behavior: "Vitest shared chunk initialization"', () => {
+ // Regression test for https://github.com/angular/angular-cli/issues/33728.
+ //
+ // Without `disableCodeSplitting`, esbuild hoists a module imported by more than one spec
+ // entry point into a shared chunk behind a lazy `__esm` initializer, and a class-field
+ // initializer in another chunk reads the exported value as `undefined` under the jsdom
+ // runner. All four trigger conditions are required and encoded below:
+ // 1. two spec entry points import the shared module (so it lands in a shared chunk);
+ // 2. a component in one entry reads the export during class-field initialization;
+ // 3. that component's spec file contains an `async` test callback (no `await` needed);
+ // 4. zone.js is in the polyfills (the `setupApplicationTarget` default), which downlevels
+ // async and makes esbuild emit the spec entry CommonJS-wrapped.
+ //
+ // NOTE: the failure this guards against is sensitive to inert content — adding a top-level
+ // side effect (even a `console.log`) to the shared or importing module below defused it
+ // during reduction. Mirror https://github.com/jonmarozick/ng-shared-chunk-repro when
+ // modifying these fixtures.
+ it('should provide shared-module exports to class-field initializers in async specs', async () => {
+ setupApplicationTarget(harness);
+
+ harness.useTarget('test', {
+ ...BASE_OPTIONS,
+ });
+
+ // Keep the default project's spec deterministic; a third spec entry that does not touch
+ // the shared module does not affect the reproduction (verified in a fresh workspace).
+ await harness.writeFile(
+ 'src/app/app.component.spec.ts',
+ `
+ import { describe, it, expect } from 'vitest';
+
+ describe('AppComponent placeholder', () => {
+ it('runs', () => {
+ expect(1 + 1).toBe(2);
+ });
+ });
+ `,
+ );
+
+ // The shared `const`. Reached from both spec entry points, so it is hoisted into a chunk
+ // shared between them.
+ await harness.writeFile(
+ 'src/environments/env-config.ts',
+ `
+ export interface DealerConfig {
+ dealerId: string;
+ clientKey: string;
+ }
+
+ export const DEALERS: DealerConfig[] = [
+ { dealerId: 'dealer-one', clientKey: 'KEY-ONE' },
+ { dealerId: 'dealer-two', clientKey: 'KEY-TWO' },
+ { dealerId: 'dealer-three', clientKey: 'KEY-THREE' },
+ { dealerId: 'dealer-four', clientKey: 'KEY-FOUR' },
+ ];
+ `,
+ );
+
+ // Reached by both spec entries, so it and env-config.ts land in the shared chunk. Reads the
+ // const inside a method — after module initialization — and is the passing control.
+ await harness.writeFile(
+ 'src/app/features/lead-generator/services/lead.service.ts',
+ `
+ import { Injectable } from '@angular/core';
+
+ import { DEALERS } from '../../../../environments/env-config';
+
+ @Injectable({ providedIn: 'root' })
+ export class LeadService {
+ resolveClientKey(dealerId: string, clientKey?: string): string {
+ return clientKey ?? DEALERS.find((d) => d.dealerId === dealerId)?.clientKey ?? '';
+ }
+ }
+ `,
+ );
+
+ await harness.writeFile(
+ 'src/app/features/lead-generator/services/index.ts',
+ `export * from './lead.service';\n`,
+ );
+
+ // Spec entry point 1 — the second importer that causes the chunk to be shared at all.
+ await harness.writeFile(
+ 'src/app/features/lead-generator/services/lead.service.spec.ts',
+ `
+ import { describe, it, expect } from 'vitest';
+ import { TestBed } from '@angular/core/testing';
+
+ import { LeadService } from './lead.service';
+
+ describe('LeadService', () => {
+ it('reads DEALERS inside a method', () => {
+ TestBed.configureTestingModule({ providers: [LeadService] });
+
+ expect(TestBed.inject(LeadService).resolveClientKey('dealer-one')).toBe('KEY-ONE');
+ });
+ });
+ `,
+ );
+
+ // In the other chunk; reads the shared export eagerly during class-field initialization.
+ await harness.writeFile(
+ 'src/app/features/lead-generator/lead-generator.container.ts',
+ `
+ import { Component, inject } from '@angular/core';
+
+ import { LeadService } from './services';
+ import { DEALERS, DealerConfig } from '../../../environments/env-config';
+
+ @Component({
+ selector: 'app-lead-generator',
+ standalone: true,
+ template: '',
+ })
+ export class LeadGeneratorContainer {
+ private readonly leadService = inject(LeadService);
+
+ readonly dealers: DealerConfig[] = DEALERS;
+ readonly dealerOptions = this.dealers.map((d) => d.dealerId);
+
+ hasService(): boolean {
+ return this.leadService != null;
+ }
+ }
+ `,
+ );
+
+ // Spec entry point 2 — the failing case without the fix. The `async` is load-bearing:
+ // zone.js makes the builder downlevel it, the spec then imports the `__async` helper, and
+ // esbuild emits this entry CommonJS-wrapped with the component module behind a lazy
+ // `__esm` initializer. No `await` is needed; a synchronous callback hides the defect.
+ await harness.writeFile(
+ 'src/app/features/lead-generator/lead-generator.container.spec.ts',
+ `
+ import { describe, it, expect } from 'vitest';
+ import { TestBed } from '@angular/core/testing';
+
+ import { LeadGeneratorContainer } from './lead-generator.container';
+
+ describe('LeadGeneratorContainer', () => {
+ it('reads DEALERS in a class-field initialiser', async () => {
+ const fixture = TestBed.createComponent(LeadGeneratorContainer);
+
+ expect(fixture.componentInstance.dealers.length).toBe(4);
+ });
+ });
+ `,
+ );
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBeTrue();
+ });
+ });
+});
diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts
index 50d57d0e5a01..1e99d84c9832 100644
--- a/packages/angular/build/src/private.ts
+++ b/packages/angular/build/src/private.ts
@@ -32,10 +32,9 @@ export {
} from './builders/dev-server/options';
// Tools
-export * from './tools/babel/plugins';
export type { ExternalResultMetadata } from './tools/esbuild/bundler-execution-result';
export { emitFilesToDisk } from './tools/esbuild/utils';
-export { transformSupportedBrowsersToTargets } from './tools/esbuild/utils';
+export { transformSupportedBrowsersToTargets } from './tools/esbuild/target';
export { SassWorkerImplementation } from './tools/sass/sass-service';
export { SourceFileCache } from './tools/esbuild/angular/source-file-cache';
diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts
index 874d66fe2b41..22ac345d413e 100644
--- a/packages/angular/build/src/tools/angular/angular-host.ts
+++ b/packages/angular/build/src/tools/angular/angular-host.ts
@@ -8,9 +8,9 @@
import type * as ng from '@angular/compiler-cli';
import assert from 'node:assert';
-import { createHash } from 'node:crypto';
import nodePath from 'node:path';
import type ts from 'typescript';
+import { calculateHash } from '../../utils/hash';
export type AngularCompilerOptions = ng.CompilerOptions;
export type AngularCompilerHost = ng.CompilerHost;
@@ -46,7 +46,7 @@ export function ensureSourceFileVersions(program: ts.Program): void {
for (const file of files) {
if (file.version === undefined) {
- file.version = createHash('sha256').update(file.text).digest('hex');
+ file.version = calculateHash(file.text);
}
}
@@ -227,7 +227,7 @@ export function createAngularCompilerHost(
// For external stylesheets, create a unique identifier and store the mapping
let externalId = hostOptions.externalStylesheets.get(resolvedPath);
if (externalId === undefined) {
- externalId = createHash('sha256').update(resolvedPath).digest('hex');
+ externalId = calculateHash(resolvedPath);
hostOptions.externalStylesheets.set(resolvedPath, externalId);
}
diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts
index 00a17ccc453e..4c17bb6e60de 100644
--- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts
@@ -19,6 +19,28 @@ export interface EmitFileResult {
dependencies?: readonly string[];
}
+export interface FileTransformResult {
+ contents: string;
+ watchFiles?: readonly string[];
+}
+
+export interface AngularCompilationOptions {
+ allowJs?: boolean;
+ isolatedModules?: boolean;
+ sourceMap?: boolean;
+ inlineSourceMap?: boolean;
+ _useTypeScriptTranspilation?: boolean;
+ [key: string]: unknown;
+}
+
+export interface AngularCompilationResult {
+ compilerOptions: AngularCompilationOptions;
+ referencedFiles: readonly string[];
+ externalStylesheets?: ReadonlyMap;
+ templateUpdates?: ReadonlyMap;
+ componentResourcesDependencies?: ReadonlyMap;
+}
+
export enum DiagnosticModes {
None = 0,
Option = 1 << 0,
@@ -70,24 +92,25 @@ export abstract class AngularCompilation {
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
- ): Promise<{
- affectedFiles: ReadonlySet;
- compilerOptions: ng.CompilerOptions;
- referencedFiles: readonly string[];
- externalStylesheets?: ReadonlyMap;
- templateUpdates?: ReadonlyMap;
- componentResourcesDependencies?: ReadonlyMap;
- }>;
-
- abstract emitAffectedFiles(): Iterable | Promise>;
-
- protected abstract collectDiagnostics(
+ ): Promise;
+
+ emitAffectedFiles(): Iterable | Promise> {
+ return [];
+ }
+
+ transformFile?(filename: string, content: string): Promise;
+
+ protected collectDiagnostics?(
modes: DiagnosticModes,
): Iterable | Promise>;
async diagnoseFiles(
modes = DiagnosticModes.All,
): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> {
+ if (!this.collectDiagnostics) {
+ return {};
+ }
+
const result: { errors?: PartialMessage[]; warnings?: PartialMessage[] } = {};
// Avoid loading typescript until actually needed.
@@ -95,7 +118,12 @@ export abstract class AngularCompilation {
const typescript = await AngularCompilation.loadTypescript();
await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => {
- for (const diagnostic of await this.collectDiagnostics(modes)) {
+ const diagnostics = await this.collectDiagnostics?.(modes);
+ if (!diagnostics) {
+ return;
+ }
+
+ for (const diagnostic of diagnostics) {
const message = convertTypeScriptDiagnostic(typescript, diagnostic);
if (diagnostic.category === typescript.DiagnosticCategory.Error) {
(result.errors ??= []).push(message);
diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts
new file mode 100644
index 000000000000..a9e93b701e5f
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts
@@ -0,0 +1,109 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import type { AngularHostOptions } from '../angular-host';
+import {
+ AngularCompilation,
+ AngularCompilationResult,
+ NoopCompilation,
+ createAngularCompilation,
+} from './index';
+
+describe('AngularCompilation', () => {
+ class CustomTransformCompilation extends AngularCompilation {
+ override async initialize(): Promise {
+ return {
+ compilerOptions: { allowJs: false },
+ referencedFiles: ['/src/main.ts'],
+ };
+ }
+
+ override async transformFile(filename: string, content: string) {
+ if (filename.endsWith('.ts')) {
+ return {
+ contents: content + '\n// transformed',
+ watchFiles: ['/src/dep.ts'],
+ };
+ }
+
+ return null;
+ }
+ }
+
+ it('allows implementing on-demand transformFile without collectDiagnostics', async () => {
+ const compilation = new CustomTransformCompilation();
+ const initResult = await compilation.initialize();
+
+ expect(initResult.referencedFiles).toEqual(['/src/main.ts']);
+ expect(initResult.compilerOptions.allowJs).toBe(false);
+
+ const transformResult = await compilation.transformFile?.(
+ '/src/main.ts',
+ 'console.log("hello");',
+ );
+ expect(transformResult).toEqual({
+ contents: 'console.log("hello");\n// transformed',
+ watchFiles: ['/src/dep.ts'],
+ });
+
+ const diagnostics = await compilation.diagnoseFiles();
+ expect(diagnostics).toEqual({});
+ });
+
+ describe('NoopCompilation', () => {
+ it('initializes with empty referencedFiles and compiler options', async () => {
+ const compilation = new NoopCompilation();
+ const mockHostOptions = {} as AngularHostOptions;
+ const result = await compilation.initialize('tsconfig.json', mockHostOptions, (opts) => ({
+ ...opts,
+ customOption: true,
+ }));
+
+ expect(result.referencedFiles).toEqual([]);
+ expect(result.compilerOptions['customOption']).toBe(true);
+ });
+
+ it('throws when calling collectDiagnostics or emitAffectedFiles', () => {
+ const compilation = new NoopCompilation();
+ expect(() =>
+ (compilation as unknown as { collectDiagnostics(): unknown }).collectDiagnostics(),
+ ).toThrowError('Not available when using noop compilation.');
+ expect(() => compilation.emitAffectedFiles()).toThrowError(
+ 'Not available when using noop compilation.',
+ );
+ });
+ });
+
+ describe('createAngularCompilation', () => {
+ it('creates JitCompilation when mode is "jit"', async () => {
+ const compilation = await createAngularCompilation('jit', true, false);
+ expect(compilation.constructor.name).toBe('JitCompilation');
+ });
+
+ it('creates AotCompilation when mode is "aot"', async () => {
+ const compilation = await createAngularCompilation('aot', true, false);
+ expect(compilation.constructor.name).toBe('AotCompilation');
+ });
+
+ it('creates JitCompilation when mode is boolean true (legacy)', async () => {
+ const compilation = await createAngularCompilation(true, true, false);
+ expect(compilation.constructor.name).toBe('JitCompilation');
+ });
+
+ it('creates AotCompilation when mode is boolean false (legacy)', async () => {
+ const compilation = await createAngularCompilation(false, true, false);
+ expect(compilation.constructor.name).toBe('AotCompilation');
+ });
+
+ it('throws when mode is "transform"', async () => {
+ await expectAsync(createAngularCompilation('transform', true, false)).toBeRejectedWithError(
+ 'Transform compilation mode is not supported.',
+ );
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
index cd4e1cdae7dd..61a9e4949fd4 100644
--- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
@@ -20,7 +20,12 @@ import {
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
-import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
+import {
+ AngularCompilation,
+ AngularCompilationResult,
+ DiagnosticModes,
+ EmitFileResult,
+} from './angular-compilation';
import { collectHmrCandidates } from './hmr-candidates';
import { printSourceFileWithMap } from './typescript-printer';
@@ -59,14 +64,7 @@ export class AotCompilation extends AngularCompilation {
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
- ): Promise<{
- affectedFiles: ReadonlySet;
- compilerOptions: ng.CompilerOptions;
- referencedFiles: readonly string[];
- externalStylesheets?: ReadonlyMap;
- templateUpdates?: ReadonlyMap;
- componentResourcesDependencies?: ReadonlyMap;
- }> {
+ ): Promise {
// Dynamically load the Angular compiler CLI package
const { NgtscProgram, OptimizeFor } = await AngularCompilation.loadCompilerCli();
@@ -231,7 +229,6 @@ export class AotCompilation extends AngularCompilation {
);
return {
- affectedFiles,
compilerOptions,
referencedFiles,
externalStylesheets: hostOptions.externalStylesheets,
@@ -240,7 +237,7 @@ export class AotCompilation extends AngularCompilation {
};
}
- *collectDiagnostics(modes: DiagnosticModes): Iterable {
+ protected override *collectDiagnostics(modes: DiagnosticModes): Iterable {
assert(this.#state, 'Angular compilation must be initialized prior to collecting diagnostics.');
const {
affectedFiles,
@@ -313,7 +310,7 @@ export class AotCompilation extends AngularCompilation {
}
}
- emitAffectedFiles(): Iterable {
+ override emitAffectedFiles(): Iterable {
assert(this.#state, 'Angular compilation must be initialized prior to emitting files.');
const {
affectedFiles,
diff --git a/packages/angular/build/src/tools/angular/compilation/factory.ts b/packages/angular/build/src/tools/angular/compilation/factory.ts
index ebfa7aa7edc4..3944979f1ee5 100644
--- a/packages/angular/build/src/tools/angular/compilation/factory.ts
+++ b/packages/angular/build/src/tools/angular/compilation/factory.ts
@@ -9,26 +9,35 @@
import { useParallelTs } from '../../../utils/environment-options';
import type { AngularCompilation } from './angular-compilation';
+export type AngularCompilationMode = 'aot' | 'jit' | 'transform';
+
/**
* Creates an Angular compilation object that can be used to perform Angular application
- * compilation either for AOT or JIT mode. By default a parallel compilation is created
+ * compilation either for AOT, JIT, or on-demand transform mode. By default a parallel compilation is created
* that uses a Node.js worker thread.
- * @param jit True, for Angular JIT compilation; False, for Angular AOT compilation.
+ * @param mode True or 'jit' for JIT mode; False or 'aot' for AOT compilation; 'transform' for on-demand transformation.
* @param browserOnlyBuild True, for browser only builds; False, for browser and server builds.
+ * @param parallel True to execute compilation in a worker thread.
* @returns An instance of an Angular compilation object.
*/
export async function createAngularCompilation(
- jit: boolean,
+ mode: boolean | AngularCompilationMode,
browserOnlyBuild: boolean,
parallel: boolean = useParallelTs,
): Promise {
+ if (mode === 'transform') {
+ throw new Error('Transform compilation mode is not supported.');
+ }
+
+ const isJit = mode === true || mode === 'jit';
+
if (parallel) {
const { ParallelCompilation } = await import('./parallel-compilation');
- return new ParallelCompilation(jit, browserOnlyBuild);
+ return new ParallelCompilation(isJit, browserOnlyBuild);
}
- if (jit) {
+ if (isJit) {
const { JitCompilation } = await import('./jit-compilation');
return new JitCompilation(browserOnlyBuild);
diff --git a/packages/angular/build/src/tools/angular/compilation/index.ts b/packages/angular/build/src/tools/angular/compilation/index.ts
index d2611e0d156d..213f55cac326 100644
--- a/packages/angular/build/src/tools/angular/compilation/index.ts
+++ b/packages/angular/build/src/tools/angular/compilation/index.ts
@@ -6,6 +6,13 @@
* found in the LICENSE file at https://angular.dev/license
*/
-export { AngularCompilation, DiagnosticModes } from './angular-compilation';
-export { createAngularCompilation } from './factory';
+export {
+ AngularCompilation,
+ type AngularCompilationOptions,
+ type AngularCompilationResult,
+ DiagnosticModes,
+ type EmitFileResult,
+ type FileTransformResult,
+} from './angular-compilation';
+export { createAngularCompilation, type AngularCompilationMode } from './factory';
export { NoopCompilation } from './noop-compilation';
diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts
index 5d920d961ba1..ffbfb9dfd7e6 100644
--- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts
@@ -14,7 +14,12 @@ import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { createJitResourceTransformer } from '../transformers/jit-resource-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
-import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
+import {
+ AngularCompilation,
+ AngularCompilationResult,
+ DiagnosticModes,
+ EmitFileResult,
+} from './angular-compilation';
class JitCompilationState {
constructor(
@@ -37,11 +42,7 @@ export class JitCompilation extends AngularCompilation {
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
- ): Promise<{
- affectedFiles: ReadonlySet;
- compilerOptions: ng.CompilerOptions;
- referencedFiles: readonly string[];
- }> {
+ ): Promise {
// Dynamically load the Angular compiler CLI package
const { constructorParametersDownlevelTransform } =
await import('@angular/compiler-cli/private/tooling');
@@ -85,10 +86,10 @@ export class JitCompilation extends AngularCompilation {
.getSourceFiles()
.map((sourceFile) => sourceFile.fileName);
- return { affectedFiles, compilerOptions, referencedFiles };
+ return { compilerOptions, referencedFiles };
}
- *collectDiagnostics(modes: DiagnosticModes): Iterable {
+ protected override *collectDiagnostics(modes: DiagnosticModes): Iterable {
assert(this.#state, 'Compilation must be initialized prior to collecting diagnostics.');
const { typeScriptProgram } = this.#state;
@@ -110,7 +111,7 @@ export class JitCompilation extends AngularCompilation {
}
}
- emitAffectedFiles(): Iterable {
+ override emitAffectedFiles(): Iterable {
assert(this.#state, 'Compilation must be initialized prior to emitting files.');
const {
compilerHost,
diff --git a/packages/angular/build/src/tools/angular/compilation/noop-compilation.ts b/packages/angular/build/src/tools/angular/compilation/noop-compilation.ts
index e2c597b4d315..a2bb722d10a8 100644
--- a/packages/angular/build/src/tools/angular/compilation/noop-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/noop-compilation.ts
@@ -7,33 +7,28 @@
*/
import type * as ng from '@angular/compiler-cli';
-import type ts from 'typescript';
import { AngularHostOptions } from '../angular-host';
-import { AngularCompilation } from './angular-compilation';
+import { AngularCompilation, AngularCompilationResult } from './angular-compilation';
export class NoopCompilation extends AngularCompilation {
async initialize(
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
- ): Promise<{
- affectedFiles: ReadonlySet;
- compilerOptions: ng.CompilerOptions;
- referencedFiles: readonly string[];
- }> {
+ ): Promise {
// Load the compiler configuration and transform as needed
const { options: originalCompilerOptions } = await this.loadConfiguration(tsconfig);
const compilerOptions =
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;
- return { affectedFiles: new Set(), compilerOptions, referencedFiles: [] };
+ return { compilerOptions, referencedFiles: [] };
}
- collectDiagnostics(): never {
+ protected override collectDiagnostics(): never {
throw new Error('Not available when using noop compilation.');
}
- emitAffectedFiles(): never {
+ override emitAffectedFiles(): never {
throw new Error('Not available when using noop compilation.');
}
}
diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts
index a0dd58179ba2..af7602d72630 100644
--- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts
@@ -10,11 +10,15 @@ import type { CompilerOptions } from '@angular/compiler-cli';
import type { PartialMessage } from 'esbuild';
import { createRequire } from 'node:module';
import { MessageChannel } from 'node:worker_threads';
-import type { SourceFile } from 'typescript';
import { WorkerPool } from '../../../utils/worker-pool';
import { mergeCumulativeDurations } from '../../esbuild/profiling';
import type { AngularHostOptions } from '../angular-host';
-import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
+import {
+ AngularCompilation,
+ AngularCompilationResult,
+ DiagnosticModes,
+ EmitFileResult,
+} from './angular-compilation';
/**
* An Angular compilation which uses a Node.js Worker thread to load and execute
@@ -47,12 +51,7 @@ export class ParallelCompilation extends AngularCompilation {
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: CompilerOptions) => CompilerOptions,
- ): Promise<{
- affectedFiles: ReadonlySet;
- compilerOptions: CompilerOptions;
- referencedFiles: readonly string[];
- externalStylesheets?: ReadonlyMap;
- }> {
+ ): Promise {
const stylesheetChannel = new MessageChannel();
// The request identifier is required because Angular can issue multiple concurrent requests
stylesheetChannel.port1.on(
diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts
index 9e41b7940989..ee3345d83388 100644
--- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts
+++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts
@@ -10,9 +10,14 @@ import type { PartialMessage } from 'esbuild';
import assert from 'node:assert';
import { randomUUID } from 'node:crypto';
import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads';
+import { initializeHash } from '../../../utils/hash';
import { SourceFileCache } from '../../esbuild/angular/source-file-cache';
import { getAndClearCumulativeDurations } from '../../esbuild/profiling';
-import type { AngularCompilation, DiagnosticModes } from './angular-compilation';
+import type {
+ AngularCompilation,
+ AngularCompilationResult,
+ DiagnosticModes,
+} from './angular-compilation';
import { AotCompilation } from './aot-compilation';
import { JitCompilation } from './jit-compilation';
@@ -32,7 +37,8 @@ let compilation: AngularCompilation | undefined;
const sourceFileCache = new SourceFileCache();
-export async function initialize(request: InitRequest) {
+export async function initialize(request: InitRequest): Promise {
+ await initializeHash();
compilation ??= request.jit
? new JitCompilation(request.browserOnlyBuild)
: new AotCompilation(request.browserOnlyBuild);
diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts
new file mode 100644
index 000000000000..5a11959fff66
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts
@@ -0,0 +1,296 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import type { AstHost, Range } from '@angular/compiler-cli/linker';
+import { FatalLinkerError } from '@angular/compiler-cli/linker';
+import type {
+ ArrayExpression,
+ ArrowFunctionExpression,
+ BooleanLiteral,
+ CallExpression,
+ Function as FunctionNode,
+ Node,
+ NullLiteral,
+ NumericLiteral,
+ ObjectExpression,
+ StringLiteral,
+ UnaryExpression,
+} from '@oxc-project/types';
+
+function isNode(node: unknown): node is Node {
+ return typeof node === 'object' && node !== null && 'type' in node;
+}
+
+/**
+ * An implementation of `AstHost` that queries information from `oxc-parser` AST nodes.
+ */
+export class OxcAstHost implements AstHost {
+ getSymbolName(node: unknown): string | null {
+ if (!isNode(node)) {
+ return null;
+ }
+
+ if (node.type === 'Identifier') {
+ return node.name;
+ } else if (node.type === 'MemberExpression') {
+ if (!node.computed && node.property.type === 'Identifier') {
+ return node.property.name;
+ }
+ }
+
+ return null;
+ }
+
+ isStringLiteral(node: unknown): node is StringLiteral {
+ return isNode(node) && node.type === 'Literal' && typeof node.value === 'string';
+ }
+
+ parseStringLiteral(str: unknown): string {
+ if (!this.isStringLiteral(str)) {
+ throw new FatalLinkerError(str as object, 'Unsupported syntax, expected a string literal.');
+ }
+
+ return str.value;
+ }
+
+ isNumericLiteral(node: unknown): node is NumericLiteral {
+ return isNode(node) && node.type === 'Literal' && typeof node.value === 'number';
+ }
+
+ parseNumericLiteral(num: unknown): number {
+ if (!this.isNumericLiteral(num)) {
+ throw new FatalLinkerError(num as object, 'Unsupported syntax, expected a numeric literal.');
+ }
+
+ return num.value;
+ }
+
+ isBooleanLiteral(node: unknown): node is BooleanLiteral | UnaryExpression {
+ if (!isNode(node)) {
+ return false;
+ }
+
+ return (
+ (node.type === 'Literal' && typeof node.value === 'boolean') || isMinifiedBooleanLiteral(node)
+ );
+ }
+
+ parseBooleanLiteral(bool: unknown): boolean {
+ if (isNode(bool)) {
+ if (bool.type === 'Literal' && typeof bool.value === 'boolean') {
+ return bool.value;
+ }
+ if (isMinifiedBooleanLiteral(bool)) {
+ return !bool.argument.value;
+ }
+ }
+
+ throw new FatalLinkerError(bool as object, 'Unsupported syntax, expected a boolean literal.');
+ }
+
+ isNull(node: unknown): node is NullLiteral {
+ return isNode(node) && node.type === 'Literal' && node.value === null;
+ }
+
+ isArrayLiteral(node: unknown): node is ArrayExpression {
+ return isNode(node) && node.type === 'ArrayExpression';
+ }
+
+ parseArrayLiteral(array: unknown): unknown[] {
+ if (!this.isArrayLiteral(array)) {
+ throw new FatalLinkerError(array as object, 'Unsupported syntax, expected an array literal.');
+ }
+
+ const result: unknown[] = [];
+
+ for (const element of array.elements) {
+ if (element === null) {
+ throw new FatalLinkerError(
+ array as object,
+ 'Unsupported syntax, element in array not to be empty.',
+ );
+ }
+ if (element.type === 'SpreadElement') {
+ throw new FatalLinkerError(
+ element as object,
+ 'Unsupported syntax, element in array not to use spread syntax.',
+ );
+ }
+ result.push(element);
+ }
+
+ return result;
+ }
+
+ isObjectLiteral(node: unknown): node is ObjectExpression {
+ return isNode(node) && node.type === 'ObjectExpression';
+ }
+
+ parseObjectLiteral(obj: unknown): Map {
+ if (!this.isObjectLiteral(obj)) {
+ throw new FatalLinkerError(obj as object, 'Unsupported syntax, expected an object literal.');
+ }
+
+ const result = new Map();
+
+ for (const property of obj.properties) {
+ if (property.type !== 'Property') {
+ throw new FatalLinkerError(
+ property as object,
+ 'Unsupported syntax, expected a property assignment.',
+ );
+ }
+
+ const keyNode = property.key;
+
+ let key: string;
+ if (keyNode.type === 'Identifier') {
+ key = keyNode.name;
+ } else if (this.isStringLiteral(keyNode)) {
+ key = keyNode.value;
+ } else if (this.isNumericLiteral(keyNode)) {
+ key = String(keyNode.value);
+ } else {
+ throw new FatalLinkerError(
+ keyNode as object,
+ 'Unsupported syntax, expected a property name.',
+ );
+ }
+
+ result.set(key, property.value);
+ }
+
+ return result;
+ }
+
+ isFunctionExpression(node: unknown): node is FunctionNode | ArrowFunctionExpression {
+ if (!isNode(node)) {
+ return false;
+ }
+
+ return (
+ node.type === 'FunctionDeclaration' ||
+ node.type === 'FunctionExpression' ||
+ node.type === 'ArrowFunctionExpression'
+ );
+ }
+
+ parseReturnValue(fn: unknown): unknown {
+ if (!this.isFunctionExpression(fn)) {
+ throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.');
+ }
+
+ const body = fn.body;
+ if (!body || !isNode(body)) {
+ throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function body.');
+ }
+
+ if (body.type !== 'BlockStatement') {
+ return body;
+ }
+
+ const statements = body.body;
+ if (statements.length !== 1) {
+ throw new FatalLinkerError(
+ body as object,
+ 'Unsupported syntax, expected a function body with a single return statement.',
+ );
+ }
+
+ const stmt = statements[0];
+ if (stmt.type !== 'ReturnStatement') {
+ throw new FatalLinkerError(
+ stmt as object,
+ 'Unsupported syntax, expected a function body with a single return statement.',
+ );
+ }
+
+ if (!stmt.argument) {
+ throw new FatalLinkerError(
+ stmt as object,
+ 'Unsupported syntax, expected function to return a value.',
+ );
+ }
+
+ return stmt.argument;
+ }
+
+ parseParameters(fn: unknown): unknown[] {
+ if (!this.isFunctionExpression(fn)) {
+ throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.');
+ }
+
+ return fn.params;
+ }
+
+ isCallExpression(node: unknown): node is CallExpression {
+ return isNode(node) && node.type === 'CallExpression';
+ }
+
+ parseCallee(call: unknown): unknown {
+ if (!this.isCallExpression(call)) {
+ throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.');
+ }
+
+ return call.callee;
+ }
+
+ parseArguments(call: unknown): unknown[] {
+ if (!this.isCallExpression(call)) {
+ throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.');
+ }
+
+ const result: unknown[] = [];
+
+ for (const arg of call.arguments) {
+ if (arg.type === 'SpreadElement') {
+ throw new FatalLinkerError(
+ arg as object,
+ 'Unsupported syntax, argument not to use spread syntax.',
+ );
+ }
+ result.push(arg);
+ }
+
+ return result;
+ }
+
+ getRange(node: unknown): Range {
+ if (!isNode(node) || typeof node.start !== 'number' || typeof node.end !== 'number') {
+ throw new FatalLinkerError(
+ node as object,
+ 'Unable to read range for node - it is missing location information.',
+ );
+ }
+
+ return {
+ startPos: node.start,
+ startLine: 0,
+ startCol: 0,
+ endPos: node.end,
+ };
+ }
+}
+
+function isMinifiedBooleanLiteral(
+ node: Node,
+): node is UnaryExpression & { argument: NumericLiteral } {
+ if (node.type !== 'UnaryExpression') {
+ return false;
+ }
+
+ const arg = node.argument;
+
+ return (
+ node.prefix === true &&
+ node.operator === '!' &&
+ arg.type === 'Literal' &&
+ typeof arg.value === 'number' &&
+ (arg.value === 0 || arg.value === 1)
+ );
+}
diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts
new file mode 100644
index 000000000000..79027584066d
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { FatalLinkerError } from '@angular/compiler-cli/linker';
+import { parseSync } from 'oxc-parser';
+import { OxcAstHost } from './oxc-ast-host';
+
+describe('OxcAstHost', () => {
+ let host: OxcAstHost;
+
+ beforeEach(() => {
+ host = new OxcAstHost();
+ });
+
+ function parseExpression(code: string): unknown {
+ const { program } = parseSync('test.js', `const x = ${code};`, { range: true });
+ const stmt = program.body[0] as { declarations: Array<{ init: unknown }> };
+
+ return stmt.declarations[0].init;
+ }
+
+ function parseStatement(code: string): unknown {
+ const { program } = parseSync('test.js', code, { range: true });
+
+ return program.body[0];
+ }
+
+ describe('getSymbolName', () => {
+ it('should return the name of an identifier', () => {
+ const expr = parseExpression('foo');
+ expect(host.getSymbolName(expr)).toBe('foo');
+ });
+
+ it('should return the property name of a member expression', () => {
+ const expr = parseExpression('foo.bar');
+ expect(host.getSymbolName(expr)).toBe('bar');
+ });
+
+ it('should return null for non-identifier or computed member expressions', () => {
+ expect(host.getSymbolName(parseExpression('foo[0]'))).toBeNull();
+ expect(host.getSymbolName(parseExpression('42'))).toBeNull();
+ expect(host.getSymbolName(null)).toBeNull();
+ });
+ });
+
+ describe('string literals', () => {
+ it('should recognize and parse valid string literals', () => {
+ const expr = parseExpression('"hello"');
+ expect(host.isStringLiteral(expr)).toBe(true);
+ expect(host.parseStringLiteral(expr)).toBe('hello');
+ });
+
+ it('should throw when parsing non-string literals', () => {
+ const expr = parseExpression('123');
+ expect(host.isStringLiteral(expr)).toBe(false);
+ expect(() => host.parseStringLiteral(expr)).toThrowError(FatalLinkerError);
+ });
+ });
+
+ describe('numeric literals', () => {
+ it('should recognize and parse valid numeric literals', () => {
+ const expr = parseExpression('123');
+ expect(host.isNumericLiteral(expr)).toBe(true);
+ expect(host.parseNumericLiteral(expr)).toBe(123);
+ });
+
+ it('should throw when parsing non-numeric literals', () => {
+ const expr = parseExpression('"123"');
+ expect(host.isNumericLiteral(expr)).toBe(false);
+ expect(() => host.parseNumericLiteral(expr)).toThrowError(FatalLinkerError);
+ });
+ });
+
+ describe('boolean literals', () => {
+ it('should recognize and parse true and false literals', () => {
+ const trueExpr = parseExpression('true');
+ const falseExpr = parseExpression('false');
+ expect(host.isBooleanLiteral(trueExpr)).toBe(true);
+ expect(host.parseBooleanLiteral(trueExpr)).toBe(true);
+ expect(host.isBooleanLiteral(falseExpr)).toBe(true);
+ expect(host.parseBooleanLiteral(falseExpr)).toBe(false);
+ });
+
+ it('should recognize and parse minified boolean literals (!0 and !1)', () => {
+ const trueExpr = parseExpression('!0');
+ const falseExpr = parseExpression('!1');
+ expect(host.isBooleanLiteral(trueExpr)).toBe(true);
+ expect(host.parseBooleanLiteral(trueExpr)).toBe(true);
+ expect(host.isBooleanLiteral(falseExpr)).toBe(true);
+ expect(host.parseBooleanLiteral(falseExpr)).toBe(false);
+ });
+
+ it('should return false for invalid boolean expressions', () => {
+ expect(host.isBooleanLiteral(parseExpression('!2'))).toBe(false);
+ expect(() => host.parseBooleanLiteral(parseExpression('!2'))).toThrowError(FatalLinkerError);
+ });
+ });
+
+ describe('array literals', () => {
+ it('should recognize and parse array literals', () => {
+ const expr = parseExpression('[1, "a", true]');
+ expect(host.isArrayLiteral(expr)).toBe(true);
+ expect(host.parseArrayLiteral(expr).length).toBe(3);
+ });
+
+ it('should throw when array contains empty elements or spread syntax', () => {
+ expect(() => host.parseArrayLiteral(parseExpression('[1, , 2]'))).toThrowError(
+ FatalLinkerError,
+ );
+ expect(() => host.parseArrayLiteral(parseExpression('[1, ...a]'))).toThrowError(
+ FatalLinkerError,
+ );
+ });
+ });
+
+ describe('object literals', () => {
+ it('should recognize and parse object literals into a Map', () => {
+ const expr = parseExpression('{ a: 1, "b": 2, 3: "c" }');
+ expect(host.isObjectLiteral(expr)).toBe(true);
+
+ const map = host.parseObjectLiteral(expr);
+ expect(map.size).toBe(3);
+ expect(map.has('a')).toBe(true);
+ expect(map.has('b')).toBe(true);
+ expect(map.has('3')).toBe(true);
+ });
+
+ it('should throw when object literal contains spread or non-property assignments', () => {
+ expect(() => host.parseObjectLiteral(parseExpression('{ ...a }'))).toThrowError(
+ FatalLinkerError,
+ );
+ });
+ });
+
+ describe('functions', () => {
+ it('should parse return value and parameters from arrow functions', () => {
+ const expr = parseExpression('(a, b) => 42');
+ expect(host.isFunctionExpression(expr)).toBe(true);
+ expect(host.parseParameters(expr).length).toBe(2);
+
+ const returnValue = host.parseReturnValue(expr);
+ expect(host.isNumericLiteral(returnValue)).toBe(true);
+ });
+
+ it('should parse return value from function with block statement containing single return', () => {
+ const stmt = parseStatement('function foo(a) { return "hello"; }');
+ expect(host.isFunctionExpression(stmt)).toBe(true);
+
+ const returnValue = host.parseReturnValue(stmt);
+ expect(host.isStringLiteral(returnValue)).toBe(true);
+ });
+
+ it('should throw when function body has multiple statements or no return', () => {
+ expect(() =>
+ host.parseReturnValue(parseStatement('function foo() { const x = 1; return x; }')),
+ ).toThrowError(FatalLinkerError);
+ expect(() => host.parseReturnValue(parseStatement('function foo() {}'))).toThrowError(
+ FatalLinkerError,
+ );
+ });
+ });
+
+ describe('call expressions', () => {
+ it('should parse callee and arguments', () => {
+ const expr = parseExpression('foo(1, "a")');
+ expect(host.isCallExpression(expr)).toBe(true);
+ expect(host.getSymbolName(host.parseCallee(expr))).toBe('foo');
+ expect(host.parseArguments(expr).length).toBe(2);
+ });
+
+ it('should throw when call expression arguments contain spread syntax', () => {
+ expect(() => host.parseArguments(parseExpression('foo(...args)'))).toThrowError(
+ FatalLinkerError,
+ );
+ });
+ });
+
+ describe('getRange', () => {
+ it('should return range object for valid AST nodes', () => {
+ const expr = parseExpression('foo');
+ const range = host.getRange(expr);
+ expect(range.startPos).toBeGreaterThanOrEqual(0);
+ expect(range.endPos).toBeGreaterThan(range.startPos);
+ });
+
+ it('should throw when node is missing range offsets', () => {
+ expect(() => host.getRange({})).toThrowError(FatalLinkerError);
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts
new file mode 100644
index 000000000000..7d085502978b
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import type { DecodedSourceMap } from '@ampproject/remapping';
+import { ConsoleLogger, LogLevel } from '@angular/compiler-cli';
+import type { DeclarationScope } from '@angular/compiler-cli/linker';
+import { FileLinker, LinkerEnvironment, needsLinking } from '@angular/compiler-cli/linker';
+import type {
+ AbsoluteFsPath,
+ ReadonlyFileSystem,
+} from '@angular/compiler-cli/src/ngtsc/file_system';
+import type { CallExpression, Node } from '@oxc-project/types';
+import MagicString from 'magic-string';
+import { parseSync, visitorKeys } from 'oxc-parser';
+import { OxcAstHost } from './oxc-ast-host';
+import { StringAstFactory } from './string-ast-factory';
+
+/**
+ * A declaration scope that instructs the Angular compiler to emit constant pools
+ * inside a local IIFE around each linked declaration rather than hoisting shared
+ * constants to the module level.
+ *
+ * Preferred due to:
+ * - In-Place String Replacement: Enables fast in-place string replacements in
+ * `MagicString` without parsing or mutating surrounding ES module statements.
+ * - Better Tree-Shaking Locality: Component constants are strictly encapsulated
+ * within the component's `@__PURE__` IIFE closure (`(function() { ... })()`). If a
+ * bundler tree-shakes an unused component from a library FESM, all of its associated
+ * constants are automatically eliminated without leaving orphan top-level variables.
+ * - Negligible Wire Size Impact: LZ77/Brotli compression deduplicates repeated IIFE
+ * wrappers and array literals over the wire to near-zero marginal cost.
+ */
+class InlineDeclarationScope implements DeclarationScope {
+ getConstantScopeRef(): null {
+ return null;
+ }
+}
+
+const noopFileSystem: ReadonlyFileSystem = {
+ exists: () => false,
+ readFile: () => '',
+ resolve: (...paths: string[]) => paths.join('/'),
+ dirname: (path: string) => path.split('/').slice(0, -1).join('/'),
+ relative: (_from: string, to: string) => to,
+} as unknown as ReadonlyFileSystem;
+
+const SHARED_LOGGER = new ConsoleLogger(LogLevel.info);
+
+const SHARED_AST_HOST = new OxcAstHost();
+const SHARED_DECLARATION_SCOPE = new InlineDeclarationScope();
+
+/**
+ * Recursively traverses ESTree AST nodes with subtree pruning.
+ * When `onCallExpression` returns `true` for a linked `CallExpression`,
+ * child traversal into `callee` and `arguments` is skipped.
+ *
+ * Why subtree pruning is safe for the linker:
+ * - Angular partial declarations (`ɵɵngDeclareComponent`, `ɵɵngDeclareDirective`,
+ * etc.) are never nested inside each other.
+ * - Once a declaration `CallExpression` is linked and replaced, there can never be
+ * another partial declaration within its metadata argument object. Pruning its
+ * subtree avoids traversing hundreds of unnecessary metadata argument nodes per
+ * component.
+ */
+function visitNode(
+ node: Node | Node[] | null | undefined,
+ onCallExpression: (node: CallExpression) => boolean,
+): void {
+ if (node === null || node === undefined || typeof node !== 'object') {
+ return;
+ }
+
+ if (Array.isArray(node)) {
+ for (let i = 0; i < node.length; i++) {
+ visitNode(node[i], onCallExpression);
+ }
+
+ return;
+ }
+
+ const nodeType = node.type;
+ if (!nodeType) {
+ return;
+ }
+
+ if (nodeType === 'CallExpression') {
+ if (onCallExpression(node)) {
+ // Subtree pruning: partial declarations cannot be nested, so skip child traversal.
+ return;
+ }
+ }
+
+ const keys = visitorKeys[nodeType];
+ if (keys) {
+ for (let i = 0; i < keys.length; i++) {
+ const child = (node as unknown as Record)[keys[i]];
+ if (child !== undefined && child !== null) {
+ visitNode(child, onCallExpression);
+ }
+ }
+ }
+}
+
+export interface OxcLinkerOptions {
+ sourcemap?: boolean;
+ jit?: boolean;
+ skipCheck?: boolean;
+}
+
+/**
+ * Executes Angular partial declaration linking on the specified JavaScript file
+ * using `oxc-parser` and `magic-string`.
+ *
+ * @param filename The full path to the file.
+ * @param code The source code content.
+ * @param options Linker options (sourcemap, jit, skipCheck).
+ * @returns An object containing the transformed code and optional source map.
+ */
+export function linkWithOxc(filename: string, code: string, options: OxcLinkerOptions = {}) {
+ if (!options.skipCheck && !needsLinking(filename, code)) {
+ return { code, map: undefined };
+ }
+
+ const astFactory = new StringAstFactory(code);
+
+ const linkerEnvironment = LinkerEnvironment.create(
+ noopFileSystem,
+ SHARED_LOGGER,
+ SHARED_AST_HOST,
+ astFactory,
+ { linkerJitMode: options.jit ?? false, sourceMapping: false },
+ );
+
+ const fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code);
+ const { program } = parseSync(filename, code, { range: true });
+
+ let s: MagicString | undefined;
+ let hasLinked = false;
+
+ visitNode(program, (node) => {
+ const calleeName = SHARED_AST_HOST.getSymbolName(node.callee);
+ if (calleeName && fileLinker.isPartialDeclaration(calleeName)) {
+ const args = SHARED_AST_HOST.parseArguments(node);
+ const linkedCode = fileLinker.linkPartialDeclaration(
+ calleeName,
+ args,
+ SHARED_DECLARATION_SCOPE,
+ );
+
+ s ??= new MagicString(code);
+ s.overwrite(node.start, node.end, linkedCode as string);
+ hasLinked = true;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ if (!hasLinked || !s) {
+ return { code, map: undefined };
+ }
+
+ let map: DecodedSourceMap | undefined;
+ if (options.sourcemap) {
+ const rawMap = s.generateDecodedMap({ hires: true, source: filename });
+ map = { ...rawMap, version: 3 };
+ }
+
+ return {
+ code: s.toString(),
+ map,
+ };
+}
diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts
new file mode 100644
index 000000000000..5f5cf6d84fe9
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { linkWithOxc } from './oxc-linker';
+
+describe('linkWithOxc', () => {
+ it('should not modify code that does not need linking', () => {
+ const input = 'const x = 1;';
+ const result = linkWithOxc('test.js', input);
+ expect(result.code).toBe(input);
+ expect(result.map).toBeUndefined();
+ });
+
+ it('should link a partial directive declaration', () => {
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ `;
+
+ const result = linkWithOxc('test.js', input);
+ expect(result.code).toContain('i0.ɵɵdefineDirective');
+ expect(result.code).not.toContain('i0.ɵɵngDeclareDirective');
+ });
+
+ it('should link a partial component declaration', () => {
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyComponent {}
+ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyComponent,
+ isStandalone: true,
+ selector: "my-cmp",
+ template: "Hello"
+ });
+ `;
+
+ const result = linkWithOxc('test.js', input);
+ expect(result.code).toContain('i0.ɵɵdefineComponent');
+ expect(result.code).not.toContain('i0.ɵɵngDeclareComponent');
+ });
+
+ it('should generate a decoded sourcemap when sourcemap option is enabled', () => {
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ `;
+
+ const result = linkWithOxc('test.js', input, { sourcemap: true });
+ expect(result.map).toBeDefined();
+ expect(result.map?.version).toBe(3);
+ expect(result.map?.sources).toContain('test.js');
+ });
+});
diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts
new file mode 100644
index 000000000000..a703856c39bd
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts
@@ -0,0 +1,335 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import type {
+ AstFactory,
+ BinaryOperator,
+ LeadingComment,
+ ObjectLiteralProperty,
+ SourceMapRange,
+ TemplateLiteral,
+ UnaryOperator,
+ VariableDeclarationType,
+} from '@angular/compiler-cli/src/ngtsc/translator';
+import type {
+ BuiltInType,
+ Parameter,
+} from '@angular/compiler-cli/src/ngtsc/translator/src/api/ast_factory';
+
+const OBJECT_LITERAL_PREFIX = '\0obj_';
+
+/**
+ * An implementation of `AstFactory` that generates JavaScript code strings directly.
+ */
+export class StringAstFactory implements AstFactory {
+ constructor(private readonly sourceCode: string = '') {}
+
+ private render(expr: unknown): string {
+ let rendered: string;
+ if (typeof expr === 'string') {
+ rendered = expr;
+ } else if (
+ typeof expr === 'object' &&
+ expr !== null &&
+ typeof (expr as { start?: number; end?: number }).start === 'number' &&
+ typeof (expr as { end?: number }).end === 'number'
+ ) {
+ const { start, end } = expr as { start: number; end: number };
+
+ rendered = this.sourceCode.slice(start, end);
+ } else {
+ rendered = String(expr);
+ }
+
+ if (rendered.startsWith(OBJECT_LITERAL_PREFIX)) {
+ return rendered.slice(OBJECT_LITERAL_PREFIX.length);
+ }
+
+ return rendered;
+ }
+
+ /**
+ * Wraps an expression in parentheses if it has syntax or operator precedence
+ * traps when used as the receiver/callee of property access, element access,
+ * call chains, `new` expressions, or tagged templates.
+ *
+ * For example, arrow functions (`(() => {}).foo`), numbers (`(5).foo`), object
+ * literals (`({ a: 1 }).foo`), and unary operators (`(void 0)()`) require
+ * parenthesization when used as receivers.
+ */
+ private wrapReceiver(receiver: unknown): string {
+ let rendered = this.render(receiver);
+ if (
+ rendered.startsWith('function') ||
+ rendered.startsWith('{') ||
+ rendered.includes('=>') ||
+ /^(?:typeof|void|delete|new|await|throw|return)\b/.test(rendered) ||
+ /^[!~+-]/.test(rendered) ||
+ /^\d/.test(rendered)
+ ) {
+ rendered = `(${rendered})`;
+ }
+
+ return rendered;
+ }
+
+ /**
+ * Wraps an expression in parentheses if it is an un-parenthesized complex
+ * operand (such as an un-parenthesized binary, logical, or assignment expression)
+ * to prevent operator precedence inversion when embedded inside an outer expression.
+ *
+ * Atomic expressions (identifiers, member/element access chains, and simple
+ * literals matching `/^[a-zA-Z0-9_$.[\]"'`]+$/`) and already-parenthesized
+ * expressions are returned as-is.
+ */
+ private wrapOperand(expr: unknown): string {
+ const rendered = this.render(expr);
+ if (
+ (rendered.startsWith('(') && rendered.endsWith(')')) ||
+ /^[a-zA-Z0-9_$.[\]"'`]+$/.test(rendered)
+ ) {
+ return rendered;
+ }
+
+ return `(${rendered})`;
+ }
+
+ /**
+ * Attaching statement-level comments is a no-op in `StringAstFactory`.
+ *
+ * Why this is safe:
+ * - Load-bearing tree-shaking annotations (`@__PURE__` markers) are explicitly prepended by
+ * `createCallExpression` and `createCallChain` whenever `pure: true`.
+ * - In `@angular/compiler`, `LeadingComment` is only used for `@ts-ignore` (suppressing
+ * TypeScript checker errors in generated `.ts` code) and `webpackChunkName` (in dynamic
+ * imports), neither of which occur during `.js`/`.mjs` library partial linking.
+ */
+ attachComments(_statement: string, _leadingComments: LeadingComment[]): void {}
+
+ createArrayLiteral(elements: unknown[]): string {
+ return `[${elements.map((e) => this.render(e)).join(', ')}]`;
+ }
+
+ createAssignment(target: unknown, operator: BinaryOperator, value: unknown): string {
+ return `(${this.render(target)} ${operator} ${this.render(value)})`;
+ }
+
+ createBinaryExpression(
+ leftOperand: unknown,
+ operator: BinaryOperator,
+ rightOperand: unknown,
+ ): string {
+ return `(${this.wrapOperand(leftOperand)} ${operator} ${this.wrapOperand(rightOperand)})`;
+ }
+
+ createBlock(body: string[]): string {
+ return `{\n${body.join('\n')}\n}`;
+ }
+
+ createCallExpression(callee: unknown, args: unknown[], pure: boolean): string {
+ const annotation = pure ? '/*@__PURE__*/ ' : '';
+
+ return `${annotation}${this.wrapReceiver(callee)}(${args.map((a) => this.render(a)).join(', ')})`;
+ }
+
+ createCallChain(callee: unknown, args: unknown[], pure: boolean, isOptional: boolean): string {
+ const annotation = pure ? '/*@__PURE__*/ ' : '';
+ const operator = isOptional ? '?.' : '';
+
+ return `${annotation}${this.wrapReceiver(callee)}${operator}(${args.map((a) => this.render(a)).join(', ')})`;
+ }
+
+ createConditional(condition: unknown, thenExpression: unknown, elseExpression: unknown): string {
+ return `(${this.render(condition)} ? ${this.render(thenExpression)} : ${this.render(elseExpression)})`;
+ }
+
+ createElementAccess(expression: unknown, element: unknown): string {
+ return `${this.wrapReceiver(expression)}[${this.render(element)}]`;
+ }
+
+ createElementAccessChain(expression: unknown, element: unknown, isOptional: boolean): string {
+ const operator = isOptional ? '?.' : '';
+
+ return `${this.wrapReceiver(expression)}${operator}[${this.render(element)}]`;
+ }
+
+ createExpressionStatement(expression: unknown): string {
+ return `${this.render(expression)};`;
+ }
+
+ createFunctionDeclaration(
+ functionName: string,
+ parameters: Parameter[],
+ body: string,
+ ): string {
+ const params = parameters.map((p) => p.name).join(', ');
+
+ return `function ${functionName}(${params}) ${body}`;
+ }
+
+ createFunctionExpression(
+ functionName: string | null,
+ parameters: Parameter[],
+ body: string,
+ ): string {
+ const name = functionName ? ` ${functionName}` : '';
+ const params = parameters.map((p) => p.name).join(', ');
+
+ return `function${name}(${params}) ${body}`;
+ }
+
+ createArrowFunctionExpression(parameters: Parameter[], body: unknown): string {
+ const params = parameters.map((p) => p.name).join(', ');
+ const isObjectLiteral = typeof body === 'string' && body.startsWith(OBJECT_LITERAL_PREFIX);
+ const renderedBody = this.render(body);
+ const formattedBody = isObjectLiteral ? `(${renderedBody})` : renderedBody;
+
+ return `(${params}) => ${formattedBody}`;
+ }
+
+ createDynamicImport(url: unknown): string {
+ return `import(${this.render(url)})`;
+ }
+
+ createIdentifier(name: string): string {
+ return name;
+ }
+
+ createIfStatement(
+ condition: unknown,
+ thenStatement: string,
+ elseStatement: string | null,
+ ): string {
+ const elseClause = elseStatement ? ` else ${elseStatement}` : '';
+
+ return `if (${this.render(condition)}) ${thenStatement}${elseClause}`;
+ }
+
+ createLiteral(value: string | number | boolean | null | undefined): string {
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
+ }
+
+ createNewExpression(expression: unknown, args: unknown[]): string {
+ return `new ${this.wrapReceiver(expression)}(${args.map((a) => this.render(a)).join(', ')})`;
+ }
+
+ createObjectLiteral(properties: ObjectLiteralProperty[]): string {
+ const props = properties.map((p) => {
+ if (p.kind === 'spread') {
+ return `...${this.render(p.expression)}`;
+ }
+
+ const key = p.quoted ? JSON.stringify(p.propertyName) : p.propertyName;
+
+ return `${key}: ${this.render(p.value)}`;
+ });
+
+ return `${OBJECT_LITERAL_PREFIX}{\n${props.join(',\n')}\n}`;
+ }
+
+ createParenthesizedExpression(expression: unknown): string {
+ return `(${this.render(expression)})`;
+ }
+
+ createPropertyAccess(expression: unknown, propertyName: string): string {
+ return `${this.wrapReceiver(expression)}.${propertyName}`;
+ }
+
+ createPropertyAccessChain(
+ expression: unknown,
+ propertyName: string,
+ isOptional: boolean,
+ ): string {
+ const operator = isOptional ? '?.' : '.';
+
+ return `${this.wrapReceiver(expression)}${operator}${propertyName}`;
+ }
+
+ createReturnStatement(expression: unknown | null): string {
+ return `return${expression !== null ? ` ${this.render(expression)}` : ''};`;
+ }
+
+ createTaggedTemplate(tag: unknown, template: TemplateLiteral): string {
+ return `${this.wrapReceiver(tag)}${this.createTemplateLiteral(template)}`;
+ }
+
+ createTemplateLiteral(template: TemplateLiteral): string {
+ let result = '`';
+ for (let i = 0; i < template.elements.length; i++) {
+ result += template.elements[i].raw;
+ if (i < template.expressions.length) {
+ result += `\${${this.render(template.expressions[i])}}`;
+ }
+ }
+ result += '`';
+
+ return result;
+ }
+
+ createThrowStatement(expression: unknown): string {
+ return `throw ${this.render(expression)};`;
+ }
+
+ createTypeOfExpression(expression: unknown): string {
+ return `typeof ${this.wrapOperand(expression)}`;
+ }
+
+ createVoidExpression(expression: unknown): string {
+ return `void ${this.wrapOperand(expression)}`;
+ }
+
+ createUnaryExpression(operator: UnaryOperator, operand: unknown): string {
+ return `${operator}${this.wrapOperand(operand)}`;
+ }
+
+ createVariableDeclaration(
+ variableName: string,
+ initializer: unknown | null,
+ variableType: VariableDeclarationType,
+ _type: string | null = null,
+ ): string {
+ const init = initializer !== null ? ` = ${this.render(initializer)}` : '';
+
+ return `${variableType} ${variableName}${init};`;
+ }
+
+ createRegularExpressionLiteral(body: string, flags: string | null): string {
+ return `/${body}/${flags ?? ''}`;
+ }
+
+ createSpreadElement(expression: unknown): string {
+ return `...${this.render(expression)}`;
+ }
+
+ createBuiltInType(_type: BuiltInType): string {
+ return '';
+ }
+
+ createExpressionType(_expression: unknown, _typeParams: string[] | null): string {
+ return '';
+ }
+
+ createArrayType(_elementType: string): string {
+ return '';
+ }
+
+ createMapType(_valueType: string): string {
+ return '';
+ }
+
+ transplantType(_type: string): string {
+ return '';
+ }
+
+ setSourceMapRange(
+ node: T,
+ _sourceMapRange: SourceMapRange | null,
+ ): T {
+ return node;
+ }
+}
diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts
new file mode 100644
index 000000000000..eb62a358dfcd
--- /dev/null
+++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts
@@ -0,0 +1,210 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { StringAstFactory } from './string-ast-factory';
+
+describe('StringAstFactory', () => {
+ let factory: StringAstFactory;
+
+ beforeEach(() => {
+ factory = new StringAstFactory('const foo = bar;');
+ });
+
+ describe('source slicing (render)', () => {
+ it('should slice original source code when given an AST object with start and end offsets', () => {
+ const astNode = { start: 6, end: 9 };
+ expect(factory.createExpressionStatement(astNode)).toBe('foo;');
+ });
+ });
+
+ describe('literals and identifiers', () => {
+ it('should correctly format string literals', () => {
+ expect(factory.createLiteral('hello')).toBe('"hello"');
+ });
+
+ it('should correctly format numeric literals including NaN and Infinity', () => {
+ expect(factory.createLiteral(123)).toBe('123');
+ expect(factory.createLiteral(NaN)).toBe('NaN');
+ expect(factory.createLiteral(Infinity)).toBe('Infinity');
+ });
+
+ it('should correctly format boolean literals', () => {
+ expect(factory.createLiteral(true)).toBe('true');
+ expect(factory.createLiteral(false)).toBe('false');
+ });
+
+ it('should correctly format null and undefined literals', () => {
+ expect(factory.createLiteral(null)).toBe('null');
+ expect(factory.createLiteral(undefined)).toBe('undefined');
+ });
+
+ it('should correctly create identifiers', () => {
+ expect(factory.createIdentifier('myVar')).toBe('myVar');
+ });
+
+ it('should correctly format regular expression literals', () => {
+ expect(factory.createRegularExpressionLiteral('abc+', 'g')).toBe('/abc+/g');
+ expect(factory.createRegularExpressionLiteral('abc+', null)).toBe('/abc+/');
+ });
+ });
+
+ describe('array and object literals', () => {
+ it('should correctly format array literals', () => {
+ expect(factory.createArrayLiteral(['1', '2', '3'])).toBe('[1, 2, 3]');
+ });
+
+ it('should correctly format object literals with quoted and unquoted keys', () => {
+ const props = [
+ { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false },
+ { kind: 'property' as const, propertyName: 'foo-bar', value: '2', quoted: true },
+ ];
+ expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n"foo-bar": 2\n}');
+ });
+
+ it('should correctly format object literals with spread properties', () => {
+ const props = [
+ { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false },
+ { kind: 'spread' as const, expression: 'bar' },
+ ];
+ expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n...bar\n}');
+ });
+ });
+
+ describe('property and element access', () => {
+ it('should correctly format property access and property access chains', () => {
+ expect(factory.createPropertyAccess('foo', 'bar')).toBe('foo.bar');
+ expect(factory.createPropertyAccessChain('foo', 'bar', true)).toBe('foo?.bar');
+ expect(factory.createPropertyAccessChain('foo', 'bar', false)).toBe('foo.bar');
+ });
+
+ it('should correctly format element access and element access chains', () => {
+ expect(factory.createElementAccess('foo', '"bar"')).toBe('foo["bar"]');
+ expect(factory.createElementAccessChain('foo', '"bar"', true)).toBe('foo?.["bar"]');
+ expect(factory.createElementAccessChain('foo', '"bar"', false)).toBe('foo["bar"]');
+ });
+
+ it('should parenthesize precedence-sensitive receivers in property and element access', () => {
+ expect(factory.createPropertyAccess('() => {}', 'bar')).toBe('(() => {}).bar');
+ expect(factory.createPropertyAccess('5', 'bar')).toBe('(5).bar');
+ expect(factory.createPropertyAccess('typeof x', 'bar')).toBe('(typeof x).bar');
+ expect(factory.createElementAccess('() => {}', '"bar"')).toBe('(() => {})["bar"]');
+ });
+ });
+
+ describe('call expressions and chains', () => {
+ it('should format standard and pure call expressions', () => {
+ expect(factory.createCallExpression('foo', ['1', '2'], false)).toBe('foo(1, 2)');
+ expect(factory.createCallExpression('foo', ['1', '2'], true)).toBe('/*@__PURE__*/ foo(1, 2)');
+ });
+
+ it('should wrap arrow functions and function expressions in parentheses when used as callees', () => {
+ expect(factory.createCallExpression('() => {}', [], false)).toBe('(() => {})()');
+ expect(factory.createCallExpression('function() {}', [], false)).toBe('(function() {})()');
+ expect(factory.createCallChain('() => {}', [], false, true)).toBe('(() => {})?.()');
+ expect(factory.createNewExpression('() => Foo', [])).toBe('new (() => Foo)()');
+ });
+
+ it('should format optional call chains', () => {
+ expect(factory.createCallChain('foo', ['1'], false, true)).toBe('foo?.(1)');
+ expect(factory.createCallChain('foo', ['1'], false, false)).toBe('foo(1)');
+ });
+
+ it('should format new expressions', () => {
+ expect(factory.createNewExpression('Foo', ['1', '2'])).toBe('new Foo(1, 2)');
+ });
+ });
+
+ describe('operators and expressions', () => {
+ it('should format binary and assignment expressions with parentheses', () => {
+ expect(factory.createBinaryExpression('a', '+', 'b')).toBe('(a + b)');
+ expect(factory.createBinaryExpression('a + b', '*', 'c + d')).toBe('((a + b) * (c + d))');
+ expect(factory.createAssignment('a', '=', 'b')).toBe('(a = b)');
+ });
+
+ it('should format conditional expressions with parentheses', () => {
+ expect(factory.createConditional('cond', 'trueVal', 'falseVal')).toBe(
+ '(cond ? trueVal : falseVal)',
+ );
+ });
+
+ it('should format unary expressions', () => {
+ expect(factory.createUnaryExpression('!', 'foo')).toBe('!foo');
+ expect(factory.createTypeOfExpression('foo')).toBe('typeof foo');
+ expect(factory.createVoidExpression('foo')).toBe('void foo');
+ expect(factory.createUnaryExpression('!', 'a + b')).toBe('!(a + b)');
+ });
+ });
+
+ describe('functions and statements', () => {
+ it('should format function declarations and expressions', () => {
+ const params = [
+ { name: 'a', type: null },
+ { name: 'b', type: null },
+ ];
+ expect(factory.createFunctionDeclaration('foo', params, '{ return a + b; }')).toBe(
+ 'function foo(a, b) { return a + b; }',
+ );
+ expect(factory.createFunctionExpression('foo', params, '{ return a + b; }')).toBe(
+ 'function foo(a, b) { return a + b; }',
+ );
+ expect(factory.createFunctionExpression(null, params, '{ return a + b; }')).toBe(
+ 'function(a, b) { return a + b; }',
+ );
+ });
+
+ it('should format arrow functions', () => {
+ const params = [
+ { name: 'a', type: null },
+ { name: 'b', type: null },
+ ];
+ expect(factory.createArrowFunctionExpression(params, 'a + b')).toBe('(a, b) => a + b');
+ });
+
+ it('should parenthesize object literal expression bodies in arrow functions even when containing semicolons', () => {
+ const params = [{ name: 'a', type: null }];
+ const obj = factory.createObjectLiteral([
+ {
+ kind: 'property',
+ propertyName: 'factory',
+ value: '() => { return new Service(); }',
+ quoted: false,
+ },
+ ]);
+ expect(factory.createArrowFunctionExpression(params, obj)).toBe(
+ '(a) => ({\nfactory: () => { return new Service(); }\n})',
+ );
+ });
+
+ it('should format statements', () => {
+ expect(factory.createBlock(['foo();', 'bar();'])).toBe('{\nfoo();\nbar();\n}');
+ expect(factory.createIfStatement('cond', 'foo();', 'bar();')).toBe(
+ 'if (cond) foo(); else bar();',
+ );
+ expect(factory.createIfStatement('cond', 'foo();', null)).toBe('if (cond) foo();');
+ expect(factory.createReturnStatement('val')).toBe('return val;');
+ expect(factory.createReturnStatement(null)).toBe('return;');
+ expect(factory.createThrowStatement('err')).toBe('throw err;');
+ expect(factory.createVariableDeclaration('x', '1', 'const')).toBe('const x = 1;');
+ expect(factory.createVariableDeclaration('y', null, 'let')).toBe('let y;');
+ });
+ });
+
+ describe('template literals', () => {
+ it('should format template literals and tagged templates', () => {
+ const template = {
+ elements: [
+ { raw: 'Hello ', cooked: 'Hello ', range: null },
+ { raw: '!', cooked: '!', range: null },
+ ],
+ expressions: ['name'],
+ };
+ expect(factory.createTemplateLiteral(template)).toBe('`Hello ${name}!`');
+ expect(factory.createTaggedTemplate('tag', template)).toBe('tag`Hello ${name}!`');
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts b/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts
deleted file mode 100644
index e549db97f8bb..000000000000
--- a/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.dev/license
- */
-
-import { NodePath, PluginObject, PluginPass, types } from '@babel/core';
-import type { Visitor } from 'istanbul-lib-instrument';
-import assert from 'node:assert';
-
-/**
- * A babel plugin factory function for adding istanbul instrumentation.
- *
- * @returns A babel plugin object instance.
- */
-export default function (
- programVisitor: typeof import('istanbul-lib-instrument').programVisitor,
-): PluginObject {
- const visitors = new WeakMap();
-
- return {
- visitor: {
- Program: {
- enter(path: NodePath, state: PluginPass) {
- const visitor = programVisitor(types, state.filename, {
- // Babel returns a Converter object from the `convert-source-map` package
- inputSourceMap: (state.file.inputMap as undefined | { toObject(): object })?.toObject(),
- });
- visitors.set(path, visitor);
-
- visitor.enter(path);
- },
- exit(path: NodePath) {
- const visitor = visitors.get(path);
- assert(visitor, 'Instrumentation visitor should always be present for program path.');
-
- visitor.exit(path);
- visitors.delete(path);
- },
- },
- },
- };
-}
diff --git a/packages/angular/build/src/tools/babel/plugins/index.ts b/packages/angular/build/src/tools/babel/plugins/index.ts
deleted file mode 100644
index dfdcd0b2d6b9..000000000000
--- a/packages/angular/build/src/tools/babel/plugins/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.dev/license
- */
-
-export { default as adjustStaticMembers } from './adjust-static-class-members';
-export { default as adjustTypeScriptEnums } from './adjust-typescript-enums';
-export { default as elideAngularMetadata } from './elide-angular-metadata';
-export { default as markTopLevelPure } from './pure-toplevel-functions';
diff --git a/packages/angular/build/src/tools/babel/plugins/types.d.ts b/packages/angular/build/src/tools/babel/plugins/types.d.ts
deleted file mode 100644
index 4ff052dcb136..000000000000
--- a/packages/angular/build/src/tools/babel/plugins/types.d.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.dev/license
- */
-
-declare module 'istanbul-lib-instrument' {
- export interface Visitor {
- enter(path: import('@babel/core').NodePath): void;
- exit(path: import('@babel/core').NodePath): void;
- }
-
- export function programVisitor(
- types: typeof import('@babel/core').types,
- filePath?: string,
- options?: { inputSourceMap?: object | null },
- ): Visitor;
-}
diff --git a/packages/angular/build/src/tools/babel/typings.d.ts b/packages/angular/build/src/tools/babel/typings.d.ts
deleted file mode 100644
index c2132a921dd9..000000000000
--- a/packages/angular/build/src/tools/babel/typings.d.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.dev/license
- */
-
-declare module '@babel/helper-annotate-as-pure' {
- export default function annotateAsPure(
- pathOrNode: import('@babel/types').Node | { node: import('@babel/types').Node },
- ): void;
-}
-
-declare module '@babel/helper-split-export-declaration' {
- export default function splitExportDeclaration(
- exportDeclaration: import('@babel/core').NodePath<
- import('@babel/types').ExportDefaultDeclaration
- >,
- ): void;
-}
diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts
index 79fb9715bdbd..286411a06057 100644
--- a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts
+++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts
@@ -46,13 +46,14 @@ export class AngularCompilationContext {
this.#pendingCompilation = true;
}
- #disposed = false;
+ #disposal: Promise | undefined;
- async dispose(): Promise {
- if (this.#disposed) {
- return;
- }
- this.#disposed = true;
+ dispose(): Promise {
+ // Reuse any in progress disposal to ensure all callers can await completion
+ return (this.#disposal ??= this.#close());
+ }
+
+ async #close(): Promise {
this.markAsReady(true);
try {
await this.#compilation.close?.();
diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
index 131547f78366..0406d4628889 100644
--- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
+++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
@@ -18,10 +18,10 @@ import type {
PluginBuild,
} from 'esbuild';
import assert from 'node:assert';
-import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { maxWorkers, useTypeChecking } from '../../../utils/environment-options';
+import { calculateHash, initializeHash } from '../../../utils/hash';
import { AngularHostOptions } from '../../angular/angular-host';
import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angular/compilation';
import { type PersistentCacheStore, createPersistentCacheStore } from '../cache';
@@ -149,6 +149,7 @@ export function createCompilerPlugin(
// eslint-disable-next-line max-lines-per-function
build.onStart(async () => {
+ await initializeHash();
angularCompilationContext.markAsInProgress();
const result: OnStartResult = {
@@ -205,11 +206,7 @@ export function createCompilerPlugin(
// invalid the output and force a full page reload for HMR cases. The containing file and order
// of the style within the containing file is used.
pluginOptions.externalRuntimeStyles
- ? createHash('sha256')
- .update(containingFile)
- .update((order ?? 0).toString())
- .update(className ?? '')
- .digest('hex')
+ ? calculateHash(`${containingFile}${order ?? 0}${className ?? ''}`)
: undefined,
);
// Adjust result source for inline styles.
@@ -365,24 +362,26 @@ export function createCompilerPlugin(
}
}
- // Update TypeScript file output cache for all affected files
- try {
- await profileAsync('NG_EMIT_TS', async () => {
- for (const { filename, contents } of await compilation.emitAffectedFiles()) {
- typeScriptFileCache.set(path.normalize(filename), contents);
- }
- });
- } catch (error) {
- (result.errors ??= []).push({
- text: 'Angular compilation emit failed.',
- location: null,
- notes: [
- {
- text: error instanceof Error ? (error.stack ?? error.message) : `${error}`,
- location: null,
- },
- ],
- });
+ // Update TypeScript file output cache for all affected files if not using on-demand transforms
+ if (!compilation.transformFile) {
+ try {
+ await profileAsync('NG_EMIT_TS', async () => {
+ for (const { filename, contents } of await compilation.emitAffectedFiles()) {
+ typeScriptFileCache.set(path.normalize(filename), contents);
+ }
+ });
+ } catch (error) {
+ (result.errors ??= []).push({
+ text: 'Angular compilation emit failed.',
+ location: null,
+ notes: [
+ {
+ text: error instanceof Error ? (error.stack ?? error.message) : `${error}`,
+ location: null,
+ },
+ ],
+ });
+ }
}
const diagnostics = await compilation.diagnoseFiles(
@@ -437,6 +436,35 @@ export function createCompilerPlugin(
// cache is later stored to disk, then the options that affect transform output
// would need to be added to the key as well as a check for any change of content.
let contents = typeScriptFileCache.get(request);
+ let directContents: string | undefined;
+
+ if (contents === undefined && compilation.transformFile) {
+ try {
+ directContents = await readFile(request, 'utf-8');
+ const transformResult = await compilation.transformFile(request, directContents);
+ if (transformResult) {
+ contents = transformResult.contents;
+ if (transformResult.watchFiles) {
+ referencedFileTracker.add(request, transformResult.watchFiles);
+ }
+ }
+ } catch (error) {
+ return {
+ errors: [
+ {
+ text: 'Angular compilation transform failed.',
+ location: { file: request },
+ notes: [
+ {
+ text: error instanceof Error ? (error.stack ?? error.message) : `${error}`,
+ location: null,
+ },
+ ],
+ },
+ ],
+ };
+ }
+ }
if (contents === undefined) {
// If the Angular compilation had errors the file may not have been emitted.
@@ -455,11 +483,11 @@ export function createCompilerPlugin(
// Evaluate whether the file requires the Angular compiler transpilation.
// If not, issue a warning but allow bundler to process the file (no type-checking).
- const directContents = await readFile(request, 'utf-8');
+ directContents ??= await readFile(request, 'utf-8');
if (!requiresAngularCompiler(directContents)) {
return {
warnings: [createMissingFileDiagnostic(request, args.path, diangosticRoot, false)],
- contents,
+ contents: directContents,
loader: 'ts',
resolveDir: path.dirname(request),
};
diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts
index 79008d140729..60c80ce057c6 100644
--- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts
+++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts
@@ -7,8 +7,8 @@
*/
import assert from 'node:assert';
-import { createHash } from 'node:crypto';
import path from 'node:path';
+import { createContentHash } from '../../../utils/hash';
import { BundleContextResult, BundlerContext } from '../bundler-context';
import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files';
import { MemoryCache } from '../cache';
@@ -103,11 +103,10 @@ export class ComponentStylesheetBundler {
): Promise {
// Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve
// to the actual stylesheet file path.
- // TODO: Consider xxhash instead for hashing
- const id = createHash('sha256')
- .update(data)
- .update(externalId ?? '')
- .digest('hex');
+ const hasher = createContentHash();
+ hasher.update(data);
+ hasher.update(externalId ?? '');
+ const id = hasher.digest();
const entry = [language, id, filename].join(';');
const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => {
diff --git a/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts
index 8a6fb6aa82a4..b7cd376d560e 100644
--- a/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts
+++ b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts
@@ -6,25 +6,55 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { join, relative } from 'node:path';
+import { join, relative, sep } from 'node:path';
const bazelBinDirectory = process.env['BAZEL_BINDIR'];
const bazelExecRoot = process.env['JS_BINARY__EXECROOT'];
+const execRootMarker = `${sep}execroot${sep}`;
+
+/**
+ * Sandboxed actions run in a copy of the execroot whose absolute path differs from
+ * `JS_BINARY__EXECROOT`, so the effective execroot is derived from the working directory.
+ */
+function effectiveExecRoot(): string | undefined {
+ const cwd = process.cwd();
+ const markerIndex = cwd.indexOf(execRootMarker);
+ if (markerIndex === -1) {
+ return bazelExecRoot;
+ }
+
+ const workspaceEnd = cwd.indexOf(sep, markerIndex + execRootMarker.length);
+
+ return workspaceEnd === -1 ? cwd : cwd.slice(0, workspaceEnd);
+}
export function rewriteForBazel(path: string): string {
if (!bazelBinDirectory || !bazelExecRoot) {
return path;
}
- const fromExecRoot = relative(bazelExecRoot, path);
+ const execRoot = effectiveExecRoot() ?? bazelExecRoot;
+
+ const fromExecRoot = relative(execRoot, path);
if (!fromExecRoot.startsWith('..')) {
return path;
}
const fromBinDirectory = relative(bazelBinDirectory, path);
- if (fromBinDirectory.startsWith('..')) {
- return path;
+ if (!fromBinDirectory.startsWith('..')) {
+ return join(execRoot, fromBinDirectory);
+ }
+
+ // A path that realpath resolved out of a symlink-staged sandbox into the
+ // unsandboxed execroot: re-anchor its execroot-relative tail.
+ const markerIndex = path.indexOf(execRootMarker);
+ if (markerIndex !== -1) {
+ const tail = path.slice(markerIndex + execRootMarker.length);
+ const workspaceEnd = tail.indexOf(sep);
+ if (workspaceEnd !== -1) {
+ return join(execRoot, tail.slice(workspaceEnd + 1));
+ }
}
- return join(bazelExecRoot, fromBinDirectory);
+ return path;
}
diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts
index 2bc38bd12a89..a408650a4f4f 100644
--- a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts
+++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts
@@ -25,6 +25,19 @@ export class SourceFileCache extends Map {
super();
}
+ /**
+ * Releases all cached content. The cached data is only needed for incremental
+ * rebuilds and can include the emitted contents of every TypeScript file in the
+ * program. The cache is repopulated if a build is performed after this is called.
+ */
+ override clear(): void {
+ super.clear();
+ this.modifiedFiles.clear();
+ this.typeScriptFileCache.clear();
+ this.loadResultCache.clear();
+ this.referencedFiles = undefined;
+ }
+
invalidate(files: Iterable): boolean {
if (files !== this.modifiedFiles) {
this.modifiedFiles.clear();
diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
index 37ff846c7400..ba1c6e1c69f7 100644
--- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
+++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
@@ -8,11 +8,11 @@
import type { BuildOptions, Plugin } from 'esbuild';
import assert from 'node:assert';
-import { createHash } from 'node:crypto';
import { extname, relative } from 'node:path';
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { Platform } from '../../builders/application/schema';
import { allowMangle } from '../../utils/environment-options';
+import { calculateHash } from '../../utils/hash';
import { toPosixPath } from '../../utils/path';
import {
SERVER_APP_ENGINE_MANIFEST_FILENAME,
@@ -71,6 +71,12 @@ export function createBrowserCodeBundleOptions(
supported: getFeatureSupport(zoneless),
};
+ if (options.disableCodeSplitting) {
+ // Splitting emits shared chunks that are read across chunk boundaries as live ESM bindings,
+ // which the unit-test runners' module loading does not reliably preserve.
+ buildOptions.splitting = false;
+ }
+
buildOptions.plugins ??= [];
buildOptions.plugins.push(
createWasmPlugin({ allowAsync: zoneless, cache: loadCache }),
@@ -566,7 +572,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu
'',
);
- footer = { js: `/**i18n:${createHash('sha256').update(i18nHash).digest('hex')}*/` };
+ footer = { js: `/**i18n:${calculateHash(i18nHash)}*/` };
}
// Core conditions that are always included
diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts
index 58f2df2a05c8..d3f3ca567a0f 100644
--- a/packages/angular/build/src/tools/esbuild/bundler-context.ts
+++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts
@@ -17,9 +17,7 @@ import {
context,
} from 'esbuild';
import assert from 'node:assert';
-import { realpathSync } from 'node:fs';
-import { basename, extname, join, relative, resolve } from 'node:path';
-import { toPosixPath } from '../../utils/path';
+import { basename, extname, join, relative } from 'node:path';
import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest';
import {
type BuildOutputFile,
@@ -66,7 +64,6 @@ export class BundlerContext {
#optionsFactory: BundlerOptionsFactory;
#shouldCacheResult: boolean;
#loadCache?: MemoryLoadResultCache;
- #realWorkspaceRoot?: string;
readonly watchFiles = new Set();
constructor(
@@ -264,17 +261,6 @@ export class BundlerContext {
}
}
- // esbuild always resolves its working directory through symbolic links (including
- // Windows directory junctions) and generates metafile paths relative to the resolved
- // path. When `preserveSymlinks` is enabled, the workspace root is intentionally not
- // resolved, and the metafile paths are then relative to a different base directory.
- // The paths are remapped so that all downstream consumers can rely on the documented
- // invariant that metafile paths are relative to the workspace root.
- this.#realWorkspaceRoot ??= realpathSync(this.workspaceRoot);
- if (this.#realWorkspaceRoot !== this.workspaceRoot) {
- remapMetafileBasePath(result.metafile, this.#realWorkspaceRoot, this.workspaceRoot);
- }
-
// Update files that should be watched.
// While this should technically not be linked to incremental mode, incremental is only
// currently enabled with watch mode where watch files are needed.
@@ -501,71 +487,6 @@ export class BundlerContext {
}
}
-/**
- * Remaps all relative paths within an esbuild metafile from one base directory to another.
- * Virtual files (e.g., `angular:` namespaced or bundler generated), external imports, and
- * non-relative paths are left unmodified.
- *
- * @param metafile The metafile to update in place.
- * @param fromBase The absolute base directory the metafile paths are currently relative to.
- * @param toBase The absolute base directory the metafile paths should be made relative to.
- */
-export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBase: string): void {
- const remapped = new Map();
- const remap = (value: string): string => {
- // Skip virtual files and paths with a scheme-like or namespace prefix (e.g., `angular:`)
- if (
- isInternalAngularFile(value) ||
- isInternalBundlerFile(value) ||
- /^[^\\/.]{2,}:/.test(value)
- ) {
- return value;
- }
-
- let result = remapped.get(value);
- if (result === undefined) {
- // esbuild metafile paths always use POSIX path separators
- result = toPosixPath(relative(toBase, resolve(fromBase, value)));
- remapped.set(value, result);
- }
-
- return result;
- };
-
- const inputs: Metafile['inputs'] = {};
- for (const [key, value] of Object.entries(metafile.inputs)) {
- for (const importRecord of value.imports) {
- if (!importRecord.external) {
- importRecord.path = remap(importRecord.path);
- }
- }
- inputs[remap(key)] = value;
- }
- metafile.inputs = inputs;
-
- const outputs: Metafile['outputs'] = {};
- for (const [key, value] of Object.entries(metafile.outputs)) {
- if (value.entryPoint !== undefined) {
- value.entryPoint = remap(value.entryPoint);
- }
- if (value.cssBundle !== undefined) {
- value.cssBundle = remap(value.cssBundle);
- }
- for (const importRecord of value.imports) {
- if (!importRecord.external) {
- importRecord.path = remap(importRecord.path);
- }
- }
- const outputInputs: (typeof value)['inputs'] = {};
- for (const [inputKey, inputValue] of Object.entries(value.inputs)) {
- outputInputs[remap(inputKey)] = inputValue;
- }
- value.inputs = outputInputs;
- outputs[remap(key)] = value;
- }
- metafile.outputs = outputs;
-}
-
function isInternalAngularFile(file: string) {
return file.startsWith('angular:');
}
diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts
deleted file mode 100644
index 8806f1d90406..000000000000
--- a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.dev/license
- */
-
-import type { Metafile } from 'esbuild';
-import { join, relative } from 'node:path';
-import { remapMetafileBasePath } from './bundler-context';
-
-describe('remapMetafileBasePath', () => {
- // Simulates a workspace root accessed through a symbolic link or Windows
- // directory junction (`toBase`) that resolves to a different real path
- // (`fromBase`), as esbuild resolves its working directory through links.
- const fromBase = join('/real', 'projects', 'demo');
- const toBase = join('/linked', 'demo');
-
- /** Creates a metafile path as esbuild would: relative to the resolved (real) base. */
- const fromBaseRelative = (filePath: string): string => relative(fromBase, join(toBase, filePath));
-
- it('remaps input and output paths onto the target base directory', () => {
- const metafile: Metafile = {
- inputs: {
- [fromBaseRelative('src/main.ts')]: { bytes: 10, imports: [] },
- },
- outputs: {
- [fromBaseRelative('main.js')]: {
- bytes: 100,
- inputs: { [fromBaseRelative('src/main.ts')]: { bytesInOutput: 10 } },
- imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }],
- exports: [],
- entryPoint: fromBaseRelative('src/main.ts'),
- cssBundle: fromBaseRelative('main.css'),
- },
- },
- };
-
- remapMetafileBasePath(metafile, fromBase, toBase);
-
- expect(Object.keys(metafile.inputs)).toEqual(['src/main.ts']);
- expect(Object.keys(metafile.outputs)).toEqual(['main.js']);
-
- const output = metafile.outputs['main.js'];
- expect(output.entryPoint).toBe('src/main.ts');
- expect(output.cssBundle).toBe('main.css');
- expect(Object.keys(output.inputs)).toEqual(['src/main.ts']);
- expect(output.imports[0].path).toBe('chunk-ABC.js');
- });
-
- it('does not modify virtual and namespaced files', () => {
- const metafile: Metafile = {
- inputs: {
- 'angular:polyfills': {
- bytes: 10,
- imports: [{ path: '', kind: 'import-statement' }],
- },
- },
- outputs: {
- [fromBaseRelative('polyfills.js')]: {
- bytes: 100,
- inputs: { 'angular:polyfills': { bytesInOutput: 10 } },
- imports: [],
- exports: [],
- entryPoint: 'angular:polyfills',
- },
- },
- };
-
- remapMetafileBasePath(metafile, fromBase, toBase);
-
- expect(Object.keys(metafile.inputs)).toEqual(['angular:polyfills']);
- expect(metafile.inputs['angular:polyfills'].imports[0].path).toBe('');
-
- const output = metafile.outputs['polyfills.js'];
- expect(output.entryPoint).toBe('angular:polyfills');
- expect(Object.keys(output.inputs)).toEqual(['angular:polyfills']);
- });
-
- it('does not modify external imports', () => {
- const externalPath = 'https://example.com/module.js';
- const metafile: Metafile = {
- inputs: {},
- outputs: {
- [fromBaseRelative('main.js')]: {
- bytes: 100,
- inputs: {},
- imports: [{ path: externalPath, kind: 'import-statement', external: true }],
- exports: [],
- },
- },
- };
-
- remapMetafileBasePath(metafile, fromBase, toBase);
-
- expect(metafile.outputs['main.js'].imports[0].path).toBe(externalPath);
- });
-});
diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts
index bebfd6a3eedd..53e17e7e427c 100644
--- a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts
+++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts
@@ -194,11 +194,21 @@ export class ExecutionResult {
return changed;
}
- async dispose(): Promise {
- await Promise.allSettled([
- ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()),
- ...this.rebuildContexts.otherContexts.map((context) => context.dispose()),
- this.componentStyleBundler.dispose(),
- ]);
+ #disposal?: Promise;
+
+ dispose(): Promise {
+ return (this.#disposal ??= this.#dispose());
+ }
+
+ async #dispose(): Promise {
+ try {
+ await Promise.allSettled([
+ ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()),
+ ...this.rebuildContexts.otherContexts.map((context) => context.dispose()),
+ this.componentStyleBundler.dispose(),
+ ]);
+ } finally {
+ this.codeBundleCache?.clear();
+ }
}
}
diff --git a/packages/angular/build/src/tools/esbuild/bundler-files.ts b/packages/angular/build/src/tools/esbuild/bundler-files.ts
index ac33d471a395..168c41e65940 100644
--- a/packages/angular/build/src/tools/esbuild/bundler-files.ts
+++ b/packages/angular/build/src/tools/esbuild/bundler-files.ts
@@ -7,7 +7,7 @@
*/
import type { OutputFile } from 'esbuild';
-import { createHash } from 'node:crypto';
+import { calculateHash } from '../../utils/hash';
export interface InitialFileRecord {
entrypoint: boolean;
@@ -63,9 +63,7 @@ export function createOutputFile(
return this.contents.byteLength;
},
get hash(): string {
- cachedHash ??= createHash('sha256')
- .update(cachedText ?? this.contents)
- .digest('hex');
+ cachedHash ??= calculateHash(cachedText ?? this.contents);
return cachedHash;
},
@@ -97,7 +95,7 @@ export function createOutputFile(
return cachedText;
},
get hash(): string {
- cachedHash ??= createHash('sha256').update(this.contents).digest('hex');
+ cachedHash ??= calculateHash(this.contents);
return cachedHash;
},
diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
index 71a055cea676..17f2424407a4 100644
--- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
+++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
@@ -6,11 +6,13 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
+import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
+import type { Node } from '@oxc-project/types';
import { MagicString } from 'magic-string';
import assert from 'node:assert';
+import { deserialize } from 'node:v8';
import { workerData } from 'node:worker_threads';
-import { Visitor, parseSync } from 'oxc-parser';
+import { parseSync, visitorKeys } from 'oxc-parser';
/**
* The options passed to the inliner for each file request
@@ -28,9 +30,11 @@ interface InlineFileRequest {
locale: string;
/**
- * The translation messages for the locale that should be used during the inlining process of the file.
+ * The serialized translation messages for the locale that should be used during the inlining
+ * process of the file. A Blob is used so that the messages are shared with the Worker by
+ * reference instead of being copied into it for every request.
*/
- translation?: Record;
+ translation?: Blob;
}
/**
@@ -53,19 +57,55 @@ interface InlineCodeRequest {
locale: string;
/**
- * The translation messages for the locale that should be used during the inlining process of the file.
+ * The serialized translation messages for the locale that should be used during the inlining
+ * process of the file. A Blob is used so that the messages are shared with the Worker by
+ * reference instead of being copied into it for every request.
*/
- translation?: Record;
+ translation?: Blob;
}
// Extract the application files and common options used for inline requests from the Worker context
-// TODO: Evaluate overall performance difference of passing translations here as well
const { files, missingTranslation, shouldOptimize } = (workerData || {}) as {
files: ReadonlyMap;
missingTranslation: 'error' | 'warning' | 'ignore';
shouldOptimize: boolean;
};
+/**
+ * The translation messages deserialized for the locale most recently requested of this Worker.
+ * Locales are inlined one at a time, so retaining only the active locale is enough to avoid
+ * deserializing the messages once per file while holding at most one set of messages in memory.
+ */
+let activeTranslation: { locale: string; messages: Promise> } | undefined;
+
+/**
+ * Deserializes the translation messages for an inline request, reusing the result for any
+ * subsequent request that targets the same locale.
+ * @param request An inline request containing the locale and its serialized messages.
+ * @returns The translation messages, or undefined if the locale has no translations.
+ */
+function loadTranslation(
+ request: InlineFileRequest | InlineCodeRequest,
+): Promise> | undefined {
+ const { locale, translation } = request;
+ if (!translation) {
+ return undefined;
+ }
+
+ if (activeTranslation?.locale !== locale) {
+ activeTranslation = {
+ locale,
+ // Deserializing within the stored promise ensures that concurrent requests for a locale
+ // share the one deserialization instead of each performing their own.
+ messages: translation
+ .arrayBuffer()
+ .then((buffer) => deserialize(new Uint8Array(buffer)) as Record),
+ };
+ }
+
+ return activeTranslation.messages;
+}
+
/**
* Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage.
* This function is the main entry for the Worker's action that is called by the worker pool.
@@ -80,7 +120,12 @@ export default async function inlineFile(request: InlineFileRequest) {
const code = await data.text();
const map = await files.get(request.filename + '.map')?.text();
- const result = await transformWithOxc(code, map && (JSON.parse(map) as SourceMapInput), request);
+ const result = await transformWithOxc(
+ code,
+ map && (JSON.parse(map) as SourceMapInput),
+ request,
+ await loadTranslation(request),
+ );
return {
file: request.filename,
@@ -98,7 +143,12 @@ export default async function inlineFile(request: InlineFileRequest) {
* @returns An object containing the inlined code.
*/
export async function inlineCode(request: InlineCodeRequest) {
- const result = await transformWithOxc(request.code, undefined, request);
+ const result = await transformWithOxc(
+ request.code,
+ undefined,
+ request,
+ await loadTranslation(request),
+ );
return {
output: result.code,
@@ -132,89 +182,218 @@ async function loadLocalizeTools(): Promise {
}
/**
- * Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation.
- * @param code A string containing the JavaScript code to transform.
- * @param map A sourcemap object for the provided JavaScript code.
- * @param options The inline request options to use.
- * @returns An object containing the code, map, and diagnostics from the transformation.
+ * Traverses ESTree AST nodes in post-order (bottom-up) without recursion.
+ * Bottom-up traversal ensures that nested `$localize` expressions are transformed and
+ * written to MagicString before outer containing templates are evaluated.
+ *
+ * @param root The root AST node to traverse.
+ * @param onExit Callback invoked on each AST node in post-order.
*/
-async function transformWithOxc(
- code: string,
- map: SourceMapInput | undefined,
- options: InlineFileRequest,
-) {
- const { program } = parseSync(options.filename, code, {
+function walkAstPostOrder(root: Node, onExit: (node: Node) => void): void {
+ const traverseStack: Node[] = [root];
+ const postOrderNodes: Node[] = [];
+
+ while (traverseStack.length > 0) {
+ const current = traverseStack.pop();
+ if (!current) {
+ continue;
+ }
+
+ postOrderNodes.push(current);
+
+ const keys = visitorKeys[current.type];
+ if (!keys) {
+ continue;
+ }
+
+ for (let i = 0; i < keys.length; i++) {
+ const child = (current as unknown as Record)[keys[i]];
+ if (!child) {
+ continue;
+ }
+
+ if (Array.isArray(child)) {
+ for (const item of child) {
+ if (item) {
+ traverseStack.push(item);
+ }
+ }
+ } else {
+ traverseStack.push(child);
+ }
+ }
+ }
+
+ // Process collected nodes in reverse order to achieve bottom-up (post-order) traversal
+ for (let i = postOrderNodes.length - 1; i >= 0; i--) {
+ onExit(postOrderNodes[i]);
+ }
+}
+
+/**
+ * Metadata for a `$localize` tagged template expression extracted from the AST.
+ */
+interface LocalizeCallSite {
+ start: number;
+ end: number;
+ messageParts: TemplateStringsArray;
+ expressions: { start: number; end: number }[];
+}
+
+/**
+ * Metadata extracted from a JavaScript file AST needed for localization inlining.
+ */
+interface FileLocalizeMetadata {
+ callSites: LocalizeCallSite[];
+ localeInsertSites: { start: number; end: number }[];
+ diagnostics?: string[];
+}
+
+/**
+ * Extracts localization call sites and locale insertion points from JavaScript code using OXC.
+ *
+ * @param filename The name of the file being processed.
+ * @param code The JavaScript source code.
+ * @returns The extracted localization metadata.
+ */
+function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMetadata {
+ const { program } = parseSync(filename, code, {
sourceType: 'unambiguous',
});
if (!program) {
- throw new Error(`Unknown error occurred parsing file "${options.filename}" with OXC.`);
+ throw new Error(`Unknown error occurred parsing file "${filename}" with OXC.`);
}
- const magicString = new MagicString(code);
- const { Diagnostics, translate } = await loadLocalizeTools();
- const diagnostics = new Diagnostics();
+ const callSites: LocalizeCallSite[] = [];
+ const localeInsertSites: { start: number; end: number }[] = [];
+ let diagnostics: string[] | undefined;
- const visitor = new Visitor({
- Literal(node) {
+ walkAstPostOrder(program, (node) => {
+ if (node.type === 'Literal') {
if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') {
- magicString.overwrite(node.start, node.end, JSON.stringify(options.locale));
+ localeInsertSites.push({ start: node.start, end: node.end });
}
- },
- 'TaggedTemplateExpression:exit'(node) {
+ } else if (node.type === 'TaggedTemplateExpression') {
if (node.tag.type === 'Identifier' && node.tag.name === '$localize') {
- const cooked = node.quasi.quasis.map((q) => q.value.cooked);
- const raw = node.quasi.quasis.map((q) => q.value.raw);
- const messageParts = Object.assign(cooked, { raw }) as unknown as TemplateStringsArray;
-
- const [translatedParts, translatedSubstitutions] = translate(
- diagnostics,
- options.translation || {},
- messageParts,
- node.quasi.expressions.map((_, index) => index),
- options.translation === undefined ? 'ignore' : missingTranslation,
- );
-
- // Reconstruct the new template/string literal replacement
- let replacement: string;
- if (translatedSubstitutions.length === 0) {
- replacement = JSON.stringify(translatedParts[0]);
- } else {
- replacement = '`';
- for (let i = 0; i < translatedParts.length; i++) {
- const escapedPart = JSON.stringify(translatedParts[i])
- .slice(1, -1)
- .replace(/\\"/g, '"')
- .replace(/`/g, '\\`')
- .replace(/\$\{/g, '\\${');
- replacement += escapedPart;
-
- if (i < translatedSubstitutions.length) {
- const originalIndex = translatedSubstitutions[i];
- const exprNode = node.quasi.expressions[originalIndex];
- const exprCode = magicString.slice(exprNode.start, exprNode.end);
- replacement += '${' + exprCode + '}';
- }
+ const cooked: string[] = [];
+ const raw: string[] = [];
+ let hasMalformedEscape = false;
+
+ for (const q of node.quasi.quasis) {
+ if (q.value.cooked === null || q.value.cooked === undefined) {
+ hasMalformedEscape = true;
+ (diagnostics ??= []).push(
+ `Malformed escape sequence in $localize template literal in file "${filename}".`,
+ );
+ break;
}
- replacement += '`';
+ cooked.push(q.value.cooked);
+ raw.push(q.value.raw);
}
- magicString.overwrite(node.start, node.end, replacement);
+ if (!hasMalformedEscape) {
+ const messageParts = Object.assign(cooked, { raw });
+ const expressions = node.quasi.expressions.map((expr) => ({
+ start: expr.start,
+ end: expr.end,
+ }));
+
+ callSites.push({
+ start: node.start,
+ end: node.end,
+ messageParts,
+ expressions,
+ });
+ }
}
- },
+ }
});
- visitor.visit(program);
+ return { callSites, localeInsertSites, diagnostics };
+}
+
+/**
+ * Inlines translations into code using previously extracted localization metadata.
+ *
+ * @param code The source code to transform.
+ * @param map Optional source map for the source code.
+ * @param metadata Extracted localization metadata.
+ * @param locale The target locale identifier.
+ * @param translation The translation messages dictionary, or undefined for untranslated locale.
+ * @param filename The name of the file being transformed.
+ * @returns The transformed code, optional remapped source map, and diagnostics.
+ */
+async function inlineLocalize(
+ code: string,
+ map: SourceMapInput | undefined,
+ metadata: FileLocalizeMetadata,
+ locale: string,
+ translation: Record | undefined,
+ filename: string,
+) {
+ const magicString = new MagicString(code);
+ const { Diagnostics, translate } = await loadLocalizeTools();
+ const diagnostics = new Diagnostics();
+
+ if (metadata.diagnostics) {
+ for (const message of metadata.diagnostics) {
+ diagnostics.error(message);
+ }
+ }
+
+ for (const site of metadata.localeInsertSites) {
+ magicString.overwrite(site.start, site.end, JSON.stringify(locale));
+ }
+
+ for (const callSite of metadata.callSites) {
+ const [translatedParts, translatedSubstitutions] = translate(
+ diagnostics,
+ translation || {},
+ callSite.messageParts,
+ callSite.expressions.map((_, index) => index),
+ translation === undefined ? 'ignore' : missingTranslation,
+ );
+
+ // Reconstruct the new template/string literal replacement
+ let replacement: string;
+ if (translatedSubstitutions.length === 0) {
+ replacement = JSON.stringify(translatedParts[0]);
+ } else {
+ replacement = '`';
+ for (let i = 0; i < translatedParts.length; i++) {
+ const escapedPart = JSON.stringify(translatedParts[i])
+ .slice(1, -1)
+ .replace(/\\"/g, '"')
+ .replace(/`/g, '\\`')
+ .replace(/\$\{/g, '\\${');
+ replacement += escapedPart;
+
+ if (i < translatedSubstitutions.length) {
+ const originalIndex = translatedSubstitutions[i];
+ const expr = callSite.expressions[originalIndex];
+ const exprCode = magicString.slice(expr.start, expr.end);
+ replacement += '${' + exprCode + '}';
+ }
+ }
+ replacement += '`';
+ }
+
+ magicString.overwrite(callSite.start, callSite.end, replacement);
+ }
const outputCode = magicString.toString();
let outputMap;
if (map && magicString.hasChanged()) {
- const rawMap = magicString.generateMap({
- source: options.filename,
+ // A decoded map is generated here rather than an encoded one because remapping decodes its
+ // inputs. Encoding the mappings only for remapping to immediately decode them again doubles
+ // the peak memory of the largest structure involved in inlining a file.
+ const rawMap = magicString.generateDecodedMap({
+ source: filename,
includeContent: true,
hires: 'boundary',
});
- outputMap = remapping([rawMap as EncodedSourceMap, map], () => null);
+ outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null);
}
return {
@@ -223,3 +402,22 @@ async function transformWithOxc(
diagnostics,
};
}
+
+/**
+ * Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation.
+ * @param code A string containing the JavaScript code to transform.
+ * @param map A sourcemap object for the provided JavaScript code.
+ * @param options The inline request options to use.
+ * @param translation The translation messages to inline, or undefined for an untranslated locale.
+ * @returns An object containing the code, map, and diagnostics from the transformation.
+ */
+async function transformWithOxc(
+ code: string,
+ map: SourceMapInput | undefined,
+ options: InlineFileRequest | InlineCodeRequest,
+ translation: Record | undefined,
+) {
+ const metadata = extractLocalizeMetadata(options.filename, code);
+
+ return inlineLocalize(code, map, metadata, options.locale, translation, options.filename);
+}
diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts
index af13226a68d4..a65d28516cce 100644
--- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts
+++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts
@@ -7,8 +7,9 @@
*/
import assert from 'node:assert';
-import { createHash } from 'node:crypto';
import { extname, join } from 'node:path';
+import { serialize } from 'node:v8';
+import { calculateHash, createContentHash } from '../../utils/hash';
import { WorkerPool } from '../../utils/worker-pool';
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
@@ -19,6 +20,20 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
*/
const LOCALIZE_KEYWORD = '$localize';
+/**
+ * Serializes the translation messages for a locale for transfer to an inliner Worker.
+ *
+ * A Blob is used because cloning one shares its data by reference, whereas sending the messages
+ * themselves copies them into a Worker for every request that carries them. A locale can contain
+ * tens of thousands of messages, which makes that copy the dominant cost of inlining a locale.
+ *
+ * @param translation The translation messages for a locale, if the locale has any.
+ * @returns A Blob containing the serialized messages, or undefined if the locale has none.
+ */
+function serializeTranslation(translation: Record | undefined): Blob | undefined {
+ return translation && new Blob([serialize(translation)]);
+}
+
/**
* Inlining options that should apply to all transformed code.
*/
@@ -27,6 +42,7 @@ export interface I18nInlinerOptions {
outputFiles: BuildOutputFile[];
shouldOptimize?: boolean;
persistentCachePath?: string;
+ localizeVersion?: string;
}
/**
@@ -116,19 +132,25 @@ export class I18nInliner {
* of the localize function keyword.
* @param locale The string representing the locale to inline.
* @param translation The translation messages to use when inlining.
+ * @param translationIntegrity An optional integrity value for the translation messages to use for caching.
* @returns A promise that resolves to an array of OutputFiles representing a translated result.
*/
async inlineForLocale(
locale: string,
translation: Record | undefined,
+ translationIntegrity?: string,
): Promise<{ outputFiles: BuildOutputFile[]; errors: string[]; warnings: string[] }> {
await this.initCache();
const { shouldOptimize, missingTranslation } = this.options;
+
+ // Serialized once here and then shared by the request for every file of this locale
+ const translationBlob = serializeTranslation(translation);
+
// Request inlining for each file that contains localize calls
const requests = [];
- let fileCacheKeyBase: Uint8Array | undefined;
+ let fileCacheKeyBase: string | undefined;
for (const [filename, file] of this.#localizeFiles) {
let cacheKey: string | undefined;
@@ -138,18 +160,25 @@ export class I18nInliner {
let cacheResultPromise = Promise.resolve(null);
if (this.#cache) {
- fileCacheKeyBase ??= Buffer.from(
- JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }),
- 'utf-8',
+ // The options are digested here so that each file's key is derived from a fixed number
+ // of bytes. Hashing the options directly would re-hash the full set of messages, which
+ // can be several megabytes, once for every file.
+ fileCacheKeyBase ??= calculateHash(
+ JSON.stringify({
+ locale,
+ translation: translationIntegrity ?? translation,
+ missingTranslation,
+ shouldOptimize,
+ localizeVersion: this.options.localizeVersion,
+ }),
);
// NOTE: If additional options are added, this may need to be updated.
- // TODO: Consider xxhash or similar instead of SHA256
- cacheKey = createHash('sha256')
- .update(file.hash)
- .update(filename)
- .update(fileCacheKeyBase)
- .digest('hex');
+ const hasher = createContentHash();
+ hasher.update(file.hash);
+ hasher.update(filename);
+ hasher.update(fileCacheKeyBase);
+ cacheKey = hasher.digest();
// Failure to get the value should not fail the transform
cacheResultPromise = this.#cache.get(cacheKey).catch(() => null);
@@ -160,7 +189,11 @@ export class I18nInliner {
return cachedResult;
}
- const result = await this.#workerPool.run({ filename, locale, translation });
+ const result = await this.#workerPool.run({
+ filename,
+ locale,
+ translation: translationBlob,
+ });
if (this.#cache && cacheKey) {
try {
// Failure to set the value should not fail the transform
@@ -227,7 +260,12 @@ export class I18nInliner {
}
const { output, messages } = await this.#workerPool.run(
- { code: templateCode, filename: templateId, locale, translation },
+ {
+ code: templateCode,
+ filename: templateId,
+ locale,
+ translation: serializeTranslation(translation),
+ },
{ name: 'inlineCode' },
);
@@ -252,8 +290,8 @@ export class I18nInliner {
* Stops all active transformation tasks and shuts down all workers.
* @returns A void promise that resolves when closing is complete.
*/
- close(): Promise {
- return this.#workerPool.destroy();
+ async close(): Promise {
+ await Promise.allSettled([this.#cache?.close(), this.#workerPool.destroy()]);
}
/**
diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
new file mode 100644
index 000000000000..94f390e2334c
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
@@ -0,0 +1,343 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { transform } from 'esbuild';
+import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
+import { I18nInliner } from './i18n-inliner';
+
+/**
+ * A module that uses a `$localize` message with an explicit message identifier so that the
+ * translations for a test can be keyed by a known name.
+ */
+const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n';
+
+/**
+ * Creates the parsed translation form that `@angular/localize` expects for a message without
+ * placeholders.
+ */
+function translationFor(message: string): Record {
+ return { messageParts: [message], placeholderNames: [], text: message };
+}
+
+function browserFile(path: string, contents: string): BuildOutputFile {
+ return createOutputFile(path, contents, BuildOutputFileType.Browser);
+}
+
+function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile {
+ const file = outputFiles.find((output) => output.path === path);
+ if (!file) {
+ throw new Error(`Expected output files to contain '${path}'.`);
+ }
+
+ return file;
+}
+
+describe('I18nInliner', () => {
+ let inliner: I18nInliner | undefined;
+
+ // A single thread is used throughout so that every file of every locale is inlined by the same
+ // Worker. Any translation state that a Worker retains between requests is then observable.
+ function createInliner(outputFiles: BuildOutputFile[]): I18nInliner {
+ inliner = new I18nInliner({ missingTranslation: 'warning', outputFiles }, 1);
+
+ return inliner;
+ }
+
+ afterEach(async () => {
+ await inliner?.close();
+ inliner = undefined;
+ });
+
+ it('inlines the translations of a locale', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
+ });
+
+ it('inlines the translations of each locale when several are inlined in sequence', async () => {
+ const localeInliner = createInliner([browserFile('main.js', GREETING_SOURCE)]);
+
+ const french = await localeInliner.inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ });
+ const german = await localeInliner.inlineForLocale('de', { greeting: translationFor('Hallo') });
+ // Repeats the first locale to cover a locale being inlined again after another has been.
+ const frenchAgain = await localeInliner.inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ });
+
+ expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"');
+ expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"');
+ });
+
+ it('inlines the translations of a locale into every file that uses them', async () => {
+ const { outputFiles } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ browserFile('chunk.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"');
+ });
+
+ it('retains the original messages for a locale without translations', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('en-US', undefined);
+
+ // A locale without translations is the source locale, so its messages are not missing.
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"');
+ });
+
+ it('warns and retains the original message when a locale is missing a translation', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', { unrelated: translationFor('Sans rapport') });
+
+ expect(errors).toEqual([]);
+ expect(warnings.length).toBe(1);
+ expect(warnings[0]).toContain('greeting');
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"');
+ });
+
+ it('replaces the locale placeholder with the locale being inlined', async () => {
+ // The placeholder is only inlined for files that use `$localize`, which is where the build
+ // inserts it, so the message is present alongside it here.
+ const { outputFiles } = await createInliner([
+ browserFile('main.js', `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ expect(findFile(outputFiles, 'main.js').text).toContain('"fr"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___');
+ });
+
+ it('remaps the source map of a file it modifies', async () => {
+ // esbuild provides a map from the emitted code back to an original file, matching what the
+ // inliner receives during a build.
+ const { code, map } = await transform(GREETING_SOURCE, {
+ sourcefile: 'greeting.ts',
+ loader: 'ts',
+ sourcemap: 'external',
+ });
+
+ const { outputFiles } = await createInliner([
+ browserFile('main.js', code),
+ browserFile('main.js.map', map),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as {
+ version: number;
+ sources: string[];
+ mappings: string;
+ };
+
+ expect(outputMap.version).toBe(3);
+ // The map must still resolve to the original file rather than to the inliner's input.
+ expect(outputMap.sources).toContain('greeting.ts');
+ expect(outputMap.mappings.length).toBeGreaterThan(0);
+ });
+
+ describe('inlineTemplateUpdate', () => {
+ it('inlines the translations of a locale into a template update', async () => {
+ const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate(
+ 'fr',
+ { greeting: translationFor('Bonjour') },
+ GREETING_SOURCE,
+ 'template-id',
+ );
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(code).toContain('"Bonjour"');
+ expect(code).not.toContain('$localize');
+ });
+
+ it('retains the original messages for a locale without translations', async () => {
+ const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate(
+ 'en-US',
+ undefined,
+ GREETING_SOURCE,
+ 'template-id',
+ );
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(code).toContain('"Hello"');
+ });
+
+ it('returns the code untouched when it has no localize calls', async () => {
+ const source = 'export const answer = 42;\n';
+ const { code } = await createInliner([]).inlineTemplateUpdate(
+ 'fr',
+ { greeting: translationFor('Bonjour') },
+ source,
+ 'template-id',
+ );
+
+ expect(code).toBe(source);
+ });
+ });
+
+ it('safely inlines translations containing special characters, quotes, and newlines', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', {
+ greeting: translationFor('Bonjour "mon ami" \\ \' \n '),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const greeting = "Bonjour \\"mon ami\\" \\\\ \' \\n ";\n',
+ );
+ });
+
+ it('inlines translations containing placeholders', async () => {
+ const source = 'export const welcome = (name) => $localize`:@@welcome:Hello ${name}!`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ welcome: {
+ messageParts: ['Bonjour ', ' !'],
+ placeholderNames: ['PH'],
+ text: 'Bonjour {$PH} !',
+ },
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const welcome = (name) => `Bonjour ${name} !`;\n',
+ );
+ });
+
+ it('inlines multiple localize calls within the same file', async () => {
+ const source =
+ 'export const a = $localize`:@@greeting:Hello`;\nexport const b = $localize`:@@farewell:Goodbye`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ farewell: translationFor('Au revoir'),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const a = "Bonjour";\nexport const b = "Au revoir";\n',
+ );
+ });
+
+ it('inlines translations across multiple files using multiple worker threads in parallel', async () => {
+ inliner = new I18nInliner(
+ {
+ missingTranslation: 'warning',
+ outputFiles: [
+ browserFile('main.js', GREETING_SOURCE),
+ browserFile('chunk1.js', GREETING_SOURCE),
+ browserFile('chunk2.js', GREETING_SOURCE),
+ browserFile('chunk3.js', GREETING_SOURCE),
+ ],
+ },
+ 4,
+ );
+
+ const { outputFiles, errors, warnings } = await inliner.inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ for (const name of ['main.js', 'chunk1.js', 'chunk2.js', 'chunk3.js']) {
+ expect(findFile(outputFiles, name).text).toBe('export const greeting = "Bonjour";\n');
+ }
+ });
+
+ it('leaves files without localize calls unmodified', async () => {
+ const { outputFiles } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ browserFile('other.js', 'export const answer = 42;\n'),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n');
+ });
+
+ it('inlines nested $localize calls in post-order', async () => {
+ const source =
+ 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ inner: translationFor('Pomme'),
+ outer: {
+ messageParts: ['Vous avez sélectionné ', ' pour la livraison.'],
+ placeholderNames: ['PH'],
+ text: 'Vous avez sélectionné {$PH} pour la livraison.',
+ },
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n',
+ );
+ });
+
+ it('reports an error diagnostic when a $localize template has a malformed escape sequence', async () => {
+ const source = 'export const msg = $localize`:@@id:\\unicode:`;\n';
+ const { errors } = await createInliner([browserFile('main.js', source)]).inlineForLocale(
+ 'fr',
+ {},
+ );
+
+ expect(errors).toEqual([
+ 'Malformed escape sequence in $localize template literal in file "main.js".',
+ ]);
+ });
+
+ it('inlines the translations of a locale when translationIntegrity is provided', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }, 'sha256-test-integrity');
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
+ });
+
+ it('inlines the translations of a locale when localizeVersion is configured in options', async () => {
+ inliner = new I18nInliner(
+ {
+ missingTranslation: 'warning',
+ outputFiles: [browserFile('main.js', GREETING_SOURCE)],
+ localizeVersion: '20.2.0',
+ },
+ 1,
+ );
+
+ const { outputFiles, errors, warnings } = await inliner.inlineForLocale(
+ 'fr',
+ { greeting: translationFor('Bonjour') },
+ 'sha256-test-integrity',
+ );
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
index 734a8a994882..4d951ec8e963 100644
--- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
@@ -6,195 +6,256 @@
* found in the LICENSE file at https://angular.dev/license
*/
+import remapping, { type DecodedSourceMap, type EncodedSourceMap } from '@ampproject/remapping';
import { type PluginItem, transformAsync } from '@babel/core';
-import fs from 'node:fs';
import { createRequire } from 'node:module';
-import path from 'node:path';
+import { workerData } from 'node:worker_threads';
import Piscina from 'piscina';
-import { removeSourceMappingURL } from '../../utils/source-map';
+import { useBabelLinker } from '../../utils/environment-options.js';
+import {
+ findTrailingSourceMapComment,
+ isTrailingSourceMapComment,
+ loadInputSourceMap,
+ loadInputSourceMapFromUrl,
+ removeSourceMappingURL,
+} from '../../utils/source-map';
+import { linkWithOxc } from '../angular/linker/oxc-linker.js';
+import { transform as transformWithOxc } from '../oxc/oxc-transform.js';
+import type { JavaScriptTransformerOptions } from './javascript-transformer';
interface JavaScriptTransformRequest {
filename: string;
data: string | Uint8Array;
- sourcemap: boolean;
- thirdPartySourcemaps: boolean;
- advancedOptimizations: boolean;
skipLinker?: boolean;
sideEffects?: boolean;
- jit: boolean;
instrumentForCoverage?: boolean;
}
+interface TransformOptions extends Omit {
+ inputSourceMap?: EncodedSourceMap;
+ isAlreadyStripped?: boolean;
+}
+
+const {
+ sourcemap = false,
+ thirdPartySourcemaps = false,
+ advancedOptimizations = false,
+ jit = false,
+} = (workerData || {}) as Partial;
+
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
-/**
- * The function name prefix for all Angular partial compilation functions.
- * Used to determine if linking of a JavaScript file is required.
- * If any additional declarations are added or otherwise changed in the linker,
- * the names MUST begin with this prefix.
- */
-const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare';
+async function instrumentCoverage(
+ filename: string,
+ data: string,
+ useInputSourcemap: boolean,
+): Promise<{ code: string; map?: EncodedSourceMap }> {
+ try {
+ let resolvedPath = 'istanbul-lib-instrument';
+ try {
+ const requireFn = createRequire(filename);
+ resolvedPath = requireFn.resolve('istanbul-lib-instrument');
+ } catch {
+ // Fallback to pool worker import traversal
+ }
+
+ const { createInstrumenter } = (await import(
+ resolvedPath
+ )) as typeof import('istanbul-lib-instrument');
+ const instrumenter = createInstrumenter({
+ produceSourceMap: useInputSourcemap,
+ esModules: true,
+ });
+
+ const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined;
+ const instrumentedCode = instrumenter.instrumentSync(
+ data,
+ filename,
+ inputSourceMap as Parameters[2],
+ );
+ const lastMap = useInputSourcemap
+ ? (instrumenter.lastSourceMap() as EncodedSourceMap)
+ : undefined;
+
+ return {
+ code: instrumentedCode,
+ map: lastMap ?? undefined,
+ };
+ } catch (error) {
+ throw new Error(
+ `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
+ { cause: error },
+ );
+ }
+}
export default async function transformJavaScript(
request: JavaScriptTransformRequest,
): Promise {
const { filename, data, ...options } = request;
- const textData = typeof data === 'string' ? data : textDecoder.decode(data);
- const transformedData = await transformJavaScriptImpl(filename, textData, options);
+ const useInputSourcemap =
+ sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
+
+ let textData: string;
+ let inputSourceMap: EncodedSourceMap | undefined;
+ let isAlreadyStripped = false;
+
+ if (typeof data !== 'string') {
+ const trailing = findTrailingSourceMapComment(data);
+ if (trailing === null) {
+ // 0 comments: fast path, no sourcemap to load or strip
+ textData = textDecoder.decode(data);
+ isAlreadyStripped = true;
+ } else if (trailing !== undefined) {
+ if (useInputSourcemap) {
+ inputSourceMap = loadInputSourceMapFromUrl(filename, trailing.urlLine);
+ if (inputSourceMap !== undefined) {
+ // Valid trailing sourcemap comment confirmed: safe to slice code buffer for transformation passes.
+ // Note: If no passes modify the code, the untouched original `data` buffer is returned below.
+ textData = textDecoder.decode(trailing.code);
+ isAlreadyStripped = true;
+ } else {
+ // Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode
+ textData = textDecoder.decode(data);
+ }
+ } else if (isTrailingSourceMapComment(trailing.urlLine)) {
+ // Valid trailing sourcemap comment confirmed: safe to slice code buffer
+ textData = textDecoder.decode(trailing.code);
+ isAlreadyStripped = true;
+ } else {
+ // Fallback to full decode and state-machine stripping
+ textData = textDecoder.decode(data);
+ }
+ } else {
+ // Multiple comments or comment not at line start: fall back to full decode and string parser
+ textData = textDecoder.decode(data);
+ }
+ } else {
+ textData = data;
+ }
+
+ const transformedData = await transformJavaScriptImpl(filename, textData, {
+ ...options,
+ inputSourceMap,
+ isAlreadyStripped,
+ });
+
+ // If no transformations modified the code, return the original untouched data buffer via `move`.
+ // This preserves any original trailing sourcemap comment and avoids re-encoding.
+ if (transformedData === textData && typeof data !== 'string') {
+ return Piscina.move(data);
+ }
- // Transfer the data via `move` instead of cloning
return Piscina.move(textEncoder.encode(transformedData));
}
-/**
- * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function.
- */
-let linkerPluginCreator:
- typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin | undefined;
-
async function transformJavaScriptImpl(
filename: string,
data: string,
- options: Omit,
+ options: TransformOptions,
): Promise {
- const shouldLink = !options.skipLinker && (await requiresLinking(filename, data));
+ const shouldLink = !options.skipLinker;
const useInputSourcemap =
- options.sourcemap &&
- (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
+ sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
- const babelPlugins: PluginItem[] = [];
+ let code = data;
+ const maps: (DecodedSourceMap | EncodedSourceMap)[] = [];
+ let coverageMap: EncodedSourceMap | undefined;
if (options.instrumentForCoverage) {
- try {
- let resolvedPath = 'istanbul-lib-instrument';
- try {
- const requireFn = createRequire(filename);
- resolvedPath = requireFn.resolve('istanbul-lib-instrument');
- } catch {
- // Fallback to pool worker import traversal
- }
-
- const istanbul = await import(resolvedPath);
- const programVisitor = istanbul.programVisitor ?? istanbul.default?.programVisitor;
-
- if (!programVisitor) {
- throw new Error('programVisitor is not available in istanbul-lib-instrument.');
- }
-
- const { default: coveragePluginFactory } =
- await import('../babel/plugins/add-code-coverage.js');
- babelPlugins.push(coveragePluginFactory(programVisitor) as unknown as PluginItem);
- } catch (error) {
- throw new Error(
- `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
- { cause: error },
- );
- }
+ const result = await instrumentCoverage(filename, code, useInputSourcemap);
+ code = result.code;
+ coverageMap = result.map;
}
if (shouldLink) {
- // Lazy load the linker plugin only when linking is required
- const linkerPlugin = await createLinkerPlugin(options);
- babelPlugins.push(linkerPlugin as unknown as PluginItem);
- }
+ if (useBabelLinker) {
+ const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel');
+ const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli');
- let code = data;
+ const result = await transformAsync(code, {
+ filename,
+ inputSourceMap: false,
+ sourceMaps: !!useInputSourcemap,
+ compact: false,
+ configFile: false,
+ babelrc: false,
+ browserslistConfigFile: false,
+ plugins: [
+ createEs2015LinkerPlugin({
+ fileSystem: {
+ exists: () => false,
+ readFile: () => '',
+ resolve: (...paths: string[]) => paths.join('/'),
+ dirname: (path: string) => path.split('/').slice(0, -1).join('/'),
+ relative: (_from: string, to: string) => to,
+ } as never,
+ logger: new ConsoleLogger(LogLevel.info),
+ linkerJitMode: jit,
+ // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
+ sourceMapping: false,
+ }) as PluginItem,
+ ],
+ });
- // If Babel is needed, run it first
- if (babelPlugins.length > 0) {
- const result = await transformAsync(code, {
- filename,
- inputSourceMap: (useInputSourcemap ? undefined : false) as undefined,
- sourceMaps: useInputSourcemap ? 'inline' : false,
- compact: false,
- configFile: false,
- babelrc: false,
- browserslistConfigFile: false,
- plugins: babelPlugins,
- });
- code = result?.code ?? code;
+ code = result?.code ?? code;
+ if (result?.map) {
+ maps.push(result.map as EncodedSourceMap);
+ }
+ } else {
+ const result = linkWithOxc(filename, code, {
+ sourcemap: useInputSourcemap,
+ jit,
+ skipCheck: true,
+ });
+ code = result.code;
+ if (result.map) {
+ maps.push(result.map);
+ }
+ }
}
// Run advanced optimizations using our fast oxc-transform
- if (options.advancedOptimizations) {
- const { transform } = await import('../babel/plugins/oxc-transform.js');
+ if (advancedOptimizations) {
const sideEffectFree = options.sideEffects === false;
const safeAngularPackage =
sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
const topLevelSafeMode = !safeAngularPackage;
- const result = transform(filename, code, {
+ const result = transformWithOxc(filename, code, {
sourcemap: useInputSourcemap,
sideEffects: options.sideEffects,
- jit: options.jit,
topLevelSafeMode,
});
code = result.code;
-
- if (useInputSourcemap && result.map) {
- // Strip old source map comment if Babel added one
- code = removeSourceMappingURL(code);
- const base64Map = Buffer.from(result.map).toString('base64');
- code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`;
+ if (result.map) {
+ maps.push(result.map);
}
}
- // Strip sourcemaps if they should not be used
- return useInputSourcemap ? code : removeSourceMappingURL(code);
-}
-
-async function requiresLinking(path: string, source: string): Promise {
- // @angular/core and @angular/compiler will cause false positives
- // Also, TypeScript files do not require linking
- if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) {
- return false;
- }
+ if (useInputSourcemap) {
+ const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data);
+ if (maps.length > 0 || coverageMap) {
+ if (!options.isAlreadyStripped) {
+ code = removeSourceMappingURL(code);
+ }
+ const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse();
+ if (baseMap) {
+ remappingChain.push(baseMap);
+ }
- // Check if the source code includes one of the declaration functions.
- // There is a low chance of a false positive but the names are fairly unique
- // and the result would be an unnecessary no-op additional plugin pass.
- return source.includes(LINKER_DECLARATION_PREFIX);
-}
+ if (remappingChain.length > 0) {
+ const finalMap = remapping(remappingChain, () => null).toString();
+ const base64Map = Buffer.from(finalMap).toString('base64');
+ code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`;
+ }
+ }
-async function createLinkerPlugin(options: Omit) {
- linkerPluginCreator ??= (await import('@angular/compiler-cli/linker/babel'))
- .createEs2015LinkerPlugin;
-
- const linkerPlugin = linkerPluginCreator({
- linkerJitMode: options.jit,
- // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
- sourceMapping: false,
- logger: {
- level: 1, // Info level
- debug(...args: string[]) {
- // eslint-disable-next-line no-console
- console.debug(args);
- },
- info(...args: string[]) {
- // eslint-disable-next-line no-console
- console.info(args);
- },
- warn(...args: string[]) {
- // eslint-disable-next-line no-console
- console.warn(args);
- },
- error(...args: string[]) {
- // eslint-disable-next-line no-console
- console.error(args);
- },
- },
- fileSystem: {
- resolve: path.resolve,
- exists: fs.existsSync,
- dirname: path.dirname,
- relative: path.relative,
- readFile: fs.readFileSync,
- // Node.JS types don't overlap the Compiler types.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- });
+ return code;
+ }
- return linkerPlugin;
+ // Strip sourcemaps if they should not be used
+ return options.isAlreadyStripped ? code : removeSourceMappingURL(code);
}
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
index de4103f57559..3ba0dfff45b1 100644
--- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
@@ -6,13 +6,41 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
+import { createContentHash } from '../../utils/hash';
import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils';
import { removeSourceMappingURL } from '../../utils/source-map';
import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool';
import { Cache } from './cache';
+const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare';
+const LINKER_DECLARATION_PREFIX_BYTES = Buffer.from(LINKER_DECLARATION_PREFIX, 'utf-8');
+
+/**
+ * Determines whether JavaScript code requires Angular linker processing.
+ *
+ * @param path The full path to the file.
+ * @param data The data (string or Buffer) of the file.
+ * @returns True if the code contains an Angular partial declaration; otherwise false.
+ */
+function requiresLinking(path: string, data: string | Uint8Array): boolean {
+ // @angular/core and @angular/compiler will cause false positives
+ // Also, TypeScript files do not require linking
+ if (/[\\/]@angular[\\/](?:compiler|core)[\\/]|\.[cm]?tsx?$/.test(path)) {
+ return false;
+ }
+
+ if (typeof data === 'string') {
+ return data.includes(LINKER_DECLARATION_PREFIX);
+ }
+
+ const dataBuffer = Buffer.isBuffer(data)
+ ? data
+ : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+
+ return dataBuffer.includes(LINKER_DECLARATION_PREFIX_BYTES);
+}
+
/**
* Transformation options that should apply to all transformed files and data.
*/
@@ -65,6 +93,7 @@ export class JavaScriptTransformer {
jit,
};
this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8');
+ this.#workerPool = this.#ensureWorkerPool();
}
/**
@@ -102,6 +131,8 @@ export class JavaScriptTransformer {
const workerPoolOptions: WorkerPoolOptions = {
filename: require.resolve('./javascript-transformer-worker'),
maxThreads: this.maxThreads,
+ minThreads: this.maxThreads,
+ workerData: this.#commonOptions,
};
// Prevent passing SSR `--import` (loader-hooks) from parent to child worker.
@@ -132,50 +163,39 @@ export class JavaScriptTransformer {
return this.#runWithThrottle(async () => {
const data = await readFile(filename);
- let result;
- let cacheKey;
+ let cacheKey: string | undefined;
if (this.cache) {
// Create a cache key from the file data and options that effect the output.
// NOTE: If additional options are added, this may need to be updated.
- // TODO: Consider xxhash or similar instead of SHA256
- const hash = createHash('sha256');
- hash.update(`${!!skipLinker}--${!!sideEffects}`);
- hash.update(data);
- hash.update(this.#fileCacheKeyBase);
- cacheKey = hash.digest('hex');
+ const hasher = createContentHash();
+ hasher.update(`${!!skipLinker}--${!!sideEffects}`);
+ hasher.update(data);
+ hasher.update(this.#fileCacheKeyBase);
+ cacheKey = hasher.digest();
try {
- result = await this.cache?.get(cacheKey);
+ const cached = await this.cache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
} catch {
// Failure to get the value should not fail the transform
}
}
- if (result === undefined) {
- // If there is no cache or no cached entry, process the file
- result = (await this.#ensureWorkerPool().run(
- {
- filename,
- data,
- skipLinker,
- sideEffects,
- instrumentForCoverage,
- ...this.#commonOptions,
- },
- {
- // The below is disable as with Yarn PNP this causes build failures with the below message
- // `Unable to deserialize cloned data`.
- transferList: process.versions.pnp ? undefined : [data.buffer],
- },
- )) as Uint8Array;
-
- // If there is a cache then store the result
- if (this.cache && cacheKey) {
- try {
- await this.cache.put(cacheKey, result);
- } catch {
- // Failure to store the value in the cache should not fail the transform
- }
+ const result = await this.transformData(
+ filename,
+ data,
+ !!skipLinker,
+ sideEffects,
+ instrumentForCoverage,
+ );
+
+ if (this.cache && cacheKey) {
+ try {
+ await this.cache.put(cacheKey, result);
+ } catch {
+ // Failure to store the value in the cache should not fail the transform
}
}
@@ -194,30 +214,49 @@ export class JavaScriptTransformer {
*/
async transformData(
filename: string,
- data: string,
+ data: string | Uint8Array,
skipLinker: boolean,
sideEffects?: boolean,
instrumentForCoverage?: boolean,
): Promise {
+ const shouldLink = !skipLinker && requiresLinking(filename, data);
+
// Perform a quick test to determine if the data needs any transformations.
// This allows directly returning the data without the worker communication overhead.
- if (skipLinker && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
+ if (!shouldLink && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
const keepSourcemap =
this.#commonOptions.sourcemap &&
(!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
- return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8');
+ if (typeof data === 'string') {
+ return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8');
+ }
+
+ return keepSourcemap ? data : removeSourceMappingURL(data);
}
- return this.#runWithThrottle(() =>
- this.#ensureWorkerPool().run({
+ // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads.
+ // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring
+ // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices.
+ // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues.
+ const isTransferable =
+ typeof data !== 'string' &&
+ data.buffer instanceof ArrayBuffer &&
+ data.byteOffset === 0 &&
+ data.byteLength === data.buffer.byteLength &&
+ !process.versions.pnp;
+
+ return this.#ensureWorkerPool().run(
+ {
filename,
data,
- skipLinker,
+ skipLinker: !shouldLink,
sideEffects,
instrumentForCoverage,
- ...this.#commonOptions,
- }),
+ },
+ {
+ transferList: isTransferable ? [data.buffer] : undefined,
+ },
);
}
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts
new file mode 100644
index 000000000000..b1cdeec07b44
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts
@@ -0,0 +1,377 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { JavaScriptTransformer } from './javascript-transformer';
+
+describe('JavaScriptTransformer sourcemaps', () => {
+ let transformer: JavaScriptTransformer;
+
+ afterEach(async () => {
+ await transformer?.close();
+ });
+
+ function extractSourcemap(code: string): Record | null {
+ const match = code.match(
+ /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/,
+ );
+ if (!match) {
+ return null;
+ }
+
+ return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8'));
+ }
+
+ it('should remap correctly when only advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/app.ts'],
+ sourcesContent: ['const x = new SomeClass();'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData('src/app.js', input, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should remap correctly when only linking is applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ thirdPartySourcemaps: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['node_modules/my-lib/directive.ts'],
+ sourcesContent: ['export class MyDirective {}'],
+ mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ //# sourceMappingURL=data:application/json;base64,${base64Map}
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/directive.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ thirdPartySourcemaps: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['node_modules/my-lib/component.ts'],
+ sourcesContent: ['export class MyComponent {}'],
+ mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyComponent {}
+ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyComponent,
+ selector: "my-cmp",
+ template: ""
+ });
+ //# sourceMappingURL=data:application/json;base64,${base64Map}
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/component.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('node_modules/my-lib/component.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should produce a valid sourcemap when no input sourcemap is present', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const input = 'var x = new SomeClass();';
+ const result = await transformer.transformData('src/app.js', input, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.js');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/counter.ts'],
+ sourcesContent: ['export function add(a: number, b: number) { return a + b; }'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData(
+ 'src/counter.js',
+ input,
+ true,
+ undefined,
+ true /* instrumentForCoverage */,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/counter.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/app.ts'],
+ sourcesContent: ['const x = new SomeClass();'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData(
+ 'src/app.js',
+ input,
+ true,
+ undefined,
+ true /* instrumentForCoverage */,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should accept a Uint8Array input in transformData', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('var x = new SomeClass();', 'utf-8');
+ const result = await transformer.transformData('src/app.js', inputBuffer, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.js');
+ expect(typeof map?.['mappings']).toBe('string');
+ });
+
+ it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from(
+ 'console.log("hello");\n//# sourceMappingURL=app.js.map',
+ 'utf-8',
+ );
+ const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true);
+ const text = Buffer.from(result).toString('utf-8');
+
+ expect(text).toBe('console.log("hello");\n');
+ });
+
+ it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8');
+ const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true);
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8');
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/lib.js',
+ inputBuffer,
+ false, // skipLinker: false
+ );
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8');
+ const result = await transformer.transformData(
+ 'node_modules/@angular/core/fesm2022/core.mjs',
+ inputBuffer,
+ false,
+ );
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8');
+
+ for (const ext of ['.ts', '.tsx', '.mts', '.cts']) {
+ const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, false);
+
+ expect(result).toBe(inputBuffer);
+ }
+ });
+
+ it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/@angular/compiler-cli/test.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+
+ expect(text).not.toContain('i0.ɵɵngDeclareDirective');
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/license-extractor.ts b/packages/angular/build/src/tools/esbuild/license-extractor.ts
index 6629bbc387bf..890ebdd9826f 100644
--- a/packages/angular/build/src/tools/esbuild/license-extractor.ts
+++ b/packages/angular/build/src/tools/esbuild/license-extractor.ts
@@ -7,7 +7,7 @@
*/
import type { Metafile } from 'esbuild';
-import { readFile } from 'node:fs/promises';
+import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
/**
@@ -30,9 +30,9 @@ const NODE_MODULE_SEGMENT = 'node_modules';
const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN ';
/**
- * A list of commonly named license files found within packages.
+ * A regular expression for commonly named license files found within packages.
*/
-const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md'];
+const LICENSE_FILE_REGEXP = /^(?:mit-)?licen[cs]e(?:$|[-._])/i;
/**
* Header text that will be added to the top of the output license extraction file.
@@ -64,6 +64,7 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`;
const seenPaths = new Set();
+ const seenPackageDirectories = new Set();
const seenPackages = new Set();
for (const entry of Object.values(metafile.outputs)) {
@@ -110,6 +111,11 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
: nameOrScope;
const packageDirectory = path.join(baseDirectory, packageName);
+ if (seenPackageDirectories.has(packageDirectory)) {
+ continue;
+ }
+ seenPackageDirectories.add(packageDirectory);
+
// Load the package's metadata to find the package's name, version, and license type
const packageJsonPath = path.join(packageDirectory, 'package.json');
let packageJson;
@@ -136,12 +142,12 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
let licenseText = '';
if (
typeof packageJson.license === 'string' &&
- packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT)
+ packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT)
) {
// Attempt to load the package's custom license
let customLicensePath;
const customLicenseFile = path.normalize(
- packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(),
+ packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(),
);
if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
// Path is attempting to access files outside of the package
@@ -150,17 +156,20 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
customLicensePath = path.join(packageDirectory, customLicenseFile);
try {
licenseText = await readFile(customLicensePath, 'utf-8');
- break;
} catch {}
}
} else {
// Search for a license file within the root of the package
- for (const potentialLicense of LICENSE_FILES) {
- const packageLicensePath = path.join(packageDirectory, potentialLicense);
- try {
- licenseText = await readFile(packageLicensePath, 'utf-8');
- break;
- } catch {}
+ const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []);
+
+ for (const entry of entries) {
+ if ((entry.isFile() || entry.isSymbolicLink()) && LICENSE_FILE_REGEXP.test(entry.name)) {
+ const packageLicensePath = path.join(packageDirectory, entry.name);
+ try {
+ licenseText = await readFile(packageLicensePath, 'utf-8');
+ break;
+ } catch {}
+ }
}
}
diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts
index 30067486a384..00f803f2fdc7 100644
--- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts
+++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts
@@ -10,7 +10,7 @@ import type { OnLoadResult, PluginBuild } from 'esbuild';
import { normalize } from 'node:path';
export interface LoadResultCache {
- get(path: string): OnLoadResult | undefined;
+ get(path: string): OnLoadResult | Promise | undefined;
put(path: string, result: OnLoadResult): Promise;
readonly watchFiles: ReadonlyArray;
}
@@ -25,7 +25,7 @@ export function createCachedLoad(
return async (args) => {
const loadCacheKey = `${args.namespace}:${args.path}`;
- let result: OnLoadResult | null | undefined = cache.get(loadCacheKey);
+ let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey);
if (result === undefined) {
result = await callback(args);
@@ -35,7 +35,9 @@ export function createCachedLoad(
// Ensure requested path is included if it was a resolved file
if (args.namespace === 'file') {
result.watchFiles ??= [];
- result.watchFiles.push(args.path);
+ if (!result.watchFiles.includes(args.path)) {
+ result.watchFiles.push(args.path);
+ }
}
await cache.put(loadCacheKey, result);
}
@@ -48,12 +50,26 @@ export function createCachedLoad(
export class MemoryLoadResultCache implements LoadResultCache {
#loadResults = new Map();
#fileDependencies = new Map>();
+ #watchFilesPerKey = new Map>();
get(path: string): OnLoadResult | undefined {
return this.#loadResults.get(path);
}
async put(path: string, result: OnLoadResult): Promise {
+ if (result.errors && result.errors.length > 0) {
+ const previousWatchFiles = this.#watchFilesPerKey.get(path);
+ if (previousWatchFiles) {
+ result.watchFiles = Array.from(
+ new Set([...(result.watchFiles ?? []), ...previousWatchFiles]),
+ );
+ }
+ } else if (result.watchFiles && result.watchFiles.length > 0) {
+ this.#watchFilesPerKey.set(path, [...result.watchFiles]);
+ } else {
+ this.#watchFilesPerKey.delete(path);
+ }
+
this.#loadResults.set(path, result);
if (result.watchFiles) {
for (const watchFile of result.watchFiles) {
@@ -90,4 +106,10 @@ export class MemoryLoadResultCache implements LoadResultCache {
// are namespaced request paths and not disk-based file paths.
return [...this.#fileDependencies.keys()];
}
+
+ clear(): void {
+ this.#loadResults.clear();
+ this.#fileDependencies.clear();
+ this.#watchFilesPerKey.clear();
+ }
}
diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts
new file mode 100644
index 000000000000..fb0396aea829
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { MemoryLoadResultCache } from './load-result-cache';
+
+describe('MemoryLoadResultCache', () => {
+ let cache: MemoryLoadResultCache;
+
+ beforeEach(() => {
+ cache = new MemoryLoadResultCache();
+ });
+
+ it('should store and retrieve results', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ };
+
+ await cache.put('file:/test/styles.css', result);
+ const cached = cache.get('file:/test/styles.css');
+
+ expect(cached).toBe(result);
+ });
+
+ it('should track watch files in fileDependencies', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', result);
+
+ expect(cache.watchFiles).toContain('/test/styles.css');
+ expect(cache.watchFiles).toContain('/test/theme.json');
+ });
+
+ it('should invalidate cached results when a dependency changes', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', result);
+ expect(cache.get('file:/test/styles.css')).toBe(result);
+
+ const invalidated = cache.invalidate('/test/theme.json');
+ expect(invalidated).toBeTrue();
+ expect(cache.get('file:/test/styles.css')).toBeUndefined();
+ });
+
+ it('should preserve previous watch files when caching an error result', async () => {
+ const successResult = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', successResult);
+ cache.invalidate('/test/theme.json');
+
+ // Simulate an incremental rebuild error result that only has the entry file in watchFiles
+ const errorResult = {
+ errors: [{ text: 'Syntax error in theme.json' }],
+ watchFiles: ['/test/styles.css'],
+ };
+
+ await cache.put('file:/test/styles.css', errorResult);
+
+ // Both the entry file and the previous dependency should be tracked
+ expect(cache.watchFiles).toContain('/test/styles.css');
+ expect(cache.watchFiles).toContain('/test/theme.json');
+
+ // Invalidating the dependency should clear the cached error result
+ const invalidated = cache.invalidate('/test/theme.json');
+ expect(invalidated).toBeTrue();
+ expect(cache.get('file:/test/styles.css')).toBeUndefined();
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts
new file mode 100644
index 000000000000..9d1e3ff5cfba
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts
@@ -0,0 +1,381 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+/**
+ * @fileoverview
+ * Implements a generic, two-tier (L1 memory + L2 persistent disk store) caching system for
+ * esbuild plugin `OnLoadResult` outcomes across the `@angular/build` compiler pipeline.
+ *
+ * Supported Module Types:
+ * 1. **Disk File Modules** (`file:` namespace): Standard disk-based source files (TS, JS, CSS, Sass, Less).
+ * Cache keys are computed using the root `globalConfigHash`, file path, and file content. Validity is
+ * verified via fast-path metadata (`mtimeMs` + `size`) with fallback to content hashing (`sha256`).
+ * 2. **Custom Plugin Namespace Modules** (e.g. `angular:script/global`, `sass:`): Modules loaded through
+ * custom esbuild namespaces that resolve to disk source files.
+ * 3. **Virtual Modules & Remote Resources** (e.g. `angular:styles/component`, `css-inline-fonts`): Synthetic
+ * in-memory modules or remote asset declarations whose compiled outcomes depend on parent source file
+ * dependencies (`watchFiles`) or global configuration options (`globalConfigHash`).
+ *
+ * Key Exported Types:
+ * - {@link PersistentLoadResultCache}: Primary two-tier cache manager implementing `LoadResultCache`.
+ * - {@link CachedLoadResultEntry}: Serialized structure persisted to disk for cached esbuild `OnLoadResult` items.
+ * - {@link CachedDependencyMetadata}: Per-dependency file metadata (`hash`, `mtimeMs`, `size`) used for cache validation and healing.
+ */
+
+import type { Loader, OnLoadResult, PartialMessage } from 'esbuild';
+import { readFile, stat } from 'node:fs/promises';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { calculateHash, createContentHash } from '../../utils/hash';
+import type { Cache as PersistentCacheStore } from './cache';
+import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
+
+/**
+ * Metadata for a single watch file dependency.
+ */
+export interface CachedDependencyMetadata {
+ hash: string;
+ mtimeMs: number;
+ size: number;
+}
+
+/**
+ * Serialized representation of any esbuild load result stored in persistent cache.
+ */
+export interface CachedLoadResultEntry {
+ /** Compiled output string or binary data */
+ contents: string | Uint8Array;
+
+ /** esbuild loader type */
+ loader?: Loader;
+
+ /** Absolute paths of all imported/watched dependency files */
+ watchFiles: string[];
+
+ /** Absolute paths of all watched directories */
+ watchDirs?: string[];
+
+ /** Map of watchFile absolute paths to dependency metadata */
+ watchFilesMetadata: Record;
+
+ /** Warnings emitted during load processing */
+ warnings?: PartialMessage[];
+
+ /** Errors emitted during load processing */
+ errors?: PartialMessage[];
+}
+
+/**
+ * Calculates a unique cache key by updating the hash incrementally.
+ * This prevents implicit string coercion of large binary content buffers.
+ */
+function calculateCacheKey(
+ globalConfigHash: string,
+ path: string,
+ content: string | Uint8Array,
+): string {
+ const hasher = createContentHash();
+ hasher.update(globalConfigHash);
+ hasher.update('\0');
+ hasher.update(path);
+ hasher.update('\0');
+ hasher.update(content);
+
+ return hasher.digest();
+}
+
+/**
+ * Normalizes a namespaced cache key into a valid disk file path if one exists.
+ * Handles 'file:' URIs, OS platform differences, and custom plugin namespaces.
+ */
+export function extractDiskFilePath(path: string): string | undefined {
+ if (path.startsWith('file:')) {
+ const urlStr = path.startsWith('file://') ? path : 'file://' + path.slice(5);
+ try {
+ return fileURLToPath(urlStr);
+ } catch {
+ const candidate = path.slice(5);
+
+ return isAbsolute(candidate) ? candidate : undefined;
+ }
+ }
+
+ // Handle custom namespace prefix (e.g. "sass:/path/to/file")
+ // Ensure colonIndex > 1 to avoid treating Windows drive letters (e.g. "C:/") as namespace prefixes.
+ const colonIndex = path.indexOf(':');
+ if (colonIndex > 1) {
+ const candidatePath = path.slice(colonIndex + 1);
+ if (isAbsolute(candidatePath)) {
+ return candidatePath;
+ }
+ }
+
+ return isAbsolute(path) ? path : undefined;
+}
+
+/** Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. */
+const MAX_CONCURRENT_READS = 16;
+
+/**
+ * Maps an array asynchronously with a sliding worker pool to maintain full concurrency saturation.
+ */
+async function mapConcurrent(
+ items: T[],
+ limit: number,
+ fn: (item: T) => Promise,
+): Promise {
+ const results: R[] = new Array(items.length);
+ let index = 0;
+
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
+ while (index < items.length) {
+ const i = index++;
+ results[i] = await fn(items[i]);
+ }
+ });
+
+ await Promise.all(workers);
+
+ return results;
+}
+
+/**
+ * Validates that all imported watch files exist on disk and their contents match.
+ * Performs a fast-path metadata check (mtime + size) first, falling back to content hashing.
+ * Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed.
+ */
+async function validateAndHealCacheEntry(
+ watchFilesMetadata: Record | undefined,
+ store: PersistentCacheStore,
+ cacheKey: string,
+ cached: CachedLoadResultEntry,
+ targetFilePath?: string,
+): Promise {
+ if (!watchFilesMetadata) {
+ return false;
+ }
+
+ const watchFiles = Object.keys(watchFilesMetadata);
+ let healed = false;
+
+ const isValidResults = await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
+ try {
+ const stats = await stat(filePath);
+ const expected = watchFilesMetadata[filePath];
+ if (!expected) {
+ return false;
+ }
+
+ // 1. Fast Path: size and mtime match
+ if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) {
+ return true;
+ }
+
+ // 2. Target File Path: content hash was already verified by cacheKey lookup, heal metadata if mtime changed
+ if (targetFilePath && filePath === targetFilePath) {
+ watchFilesMetadata[filePath] = {
+ ...expected,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ healed = true;
+
+ return true;
+ }
+
+ // 3. Slow Path for dependencies: content hash fallback
+ const currentContent = await readFile(filePath);
+ const currentHash = calculateHash(currentContent);
+ if (currentHash === expected.hash) {
+ // Heal cache entry with new metadata
+ watchFilesMetadata[filePath] = {
+ ...expected,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ healed = true;
+
+ return true;
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+ });
+
+ if (isValidResults.some((isValid) => !isValid)) {
+ return false;
+ }
+
+ if (healed) {
+ try {
+ await store.put(cacheKey, cached);
+ } catch {
+ // Ignore errors writing healed entries
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Computes metadata (content hashes, mtime, size) for an array of watch file paths.
+ * Processes files with a sliding worker pool of 16 concurrent operations.
+ */
+async function computeMetadataForWatchFiles(
+ watchFiles: string[],
+ knownContents?: Map,
+): Promise> {
+ const watchFilesMetadata: Record = {};
+
+ await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
+ try {
+ const knownContent = knownContents?.get(filePath);
+ const [content, stats] = await Promise.all([
+ knownContent !== undefined ? knownContent : readFile(filePath),
+ stat(filePath),
+ ]);
+ const hash = calculateHash(content);
+ watchFilesMetadata[filePath] = {
+ hash,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ } catch {
+ // Ignore unreadable files
+ }
+ });
+
+ return watchFilesMetadata;
+}
+
+export class PersistentLoadResultCache implements LoadResultCache {
+ private readonly memoryCache = new MemoryLoadResultCache();
+
+ constructor(
+ private readonly persistentStore?: PersistentCacheStore,
+ private readonly globalConfigHash: string = '',
+ ) {}
+
+ /**
+ * Retrieves a load result from cache.
+ * Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
+ * store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata.
+ */
+ async get(path: string): Promise {
+ // 1. Check L1 Memory Cache
+ const memoryResult = this.memoryCache.get(path);
+ if (memoryResult) {
+ return memoryResult;
+ }
+
+ if (!this.persistentStore) {
+ return undefined;
+ }
+
+ // 2. Check L2 Persistent Disk Cache
+ let content: string | Uint8Array = '';
+ const filePath = extractDiskFilePath(path);
+ if (filePath) {
+ try {
+ content = await readFile(filePath);
+ } catch {
+ return undefined;
+ }
+ }
+
+ const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
+ const cached = await this.persistentStore.get(cacheKey);
+
+ if (
+ cached &&
+ (await validateAndHealCacheEntry(
+ cached.watchFilesMetadata,
+ this.persistentStore,
+ cacheKey,
+ cached,
+ filePath,
+ ))
+ ) {
+ const result: OnLoadResult = {
+ contents: cached.contents,
+ loader: cached.loader,
+ watchFiles: cached.watchFiles,
+ watchDirs: cached.watchDirs,
+ warnings: cached.warnings,
+ errors: cached.errors,
+ };
+
+ // Populate L1 Memory Cache for subsequent lookups
+ await this.memoryCache.put(path, result);
+
+ return result;
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Stores a load result in both L1 memory cache and L2 persistent disk store.
+ */
+ async put(path: string, result: OnLoadResult): Promise {
+ await this.memoryCache.put(path, result);
+
+ // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers)
+ if (this.persistentStore && result.contents !== undefined) {
+ let content: string | Uint8Array = '';
+ const filePath = extractDiskFilePath(path);
+ if (filePath) {
+ try {
+ content = await readFile(filePath);
+ } catch {
+ // Skip L2 persistent store if target disk file cannot be read
+ return;
+ }
+ }
+
+ const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
+
+ // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile)
+ // during dependency watch file metadata computation.
+ const knownContents = filePath
+ ? new Map([[filePath, content]])
+ : undefined;
+ const watchFilesMetadata = await computeMetadataForWatchFiles(
+ result.watchFiles ?? [],
+ knownContents,
+ );
+
+ await this.persistentStore.put(cacheKey, {
+ contents: result.contents,
+ loader: result.loader,
+ watchFiles: result.watchFiles ?? [],
+ watchDirs: result.watchDirs,
+ watchFilesMetadata,
+ warnings: result.warnings,
+ errors: result.errors,
+ });
+ }
+ }
+
+ /**
+ * Invalidates cached entries affected by a modified dependency file during watch mode.
+ *
+ * Note: Invalidation of L1 memory cache is sufficient for active watch mode.
+ * Cross-process/cold start stale entries in L2 persistent store are automatically handled
+ * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`).
+ */
+ invalidate(path: string): boolean {
+ return this.memoryCache.invalidate(path);
+ }
+
+ get watchFiles(): ReadonlyArray {
+ return this.memoryCache.watchFiles;
+ }
+}
diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts
new file mode 100644
index 000000000000..f8ecc0611818
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts
@@ -0,0 +1,263 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import type { OnLoadResult } from 'esbuild';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { initializeHash } from '../../utils/hash';
+import type { Cache as PersistentCacheStore } from './cache';
+import {
+ type CachedLoadResultEntry,
+ PersistentLoadResultCache,
+ extractDiskFilePath,
+} from './persistent-load-result-cache';
+
+describe('extractDiskFilePath', () => {
+ it('should extract disk file path for file: URIs', () => {
+ const filePath = '/Users/test/project/file.js';
+ expect(extractDiskFilePath(`file://${filePath}`)).toBe(filePath);
+ });
+
+ it('should extract disk file path for custom plugin namespaces', () => {
+ const filePath = '/Users/test/project/file.js';
+ expect(extractDiskFilePath(`sass:${filePath}`)).toBe(filePath);
+ });
+
+ it('should not strip Windows drive letter as a namespace prefix', () => {
+ const winPath = 'C:/Users/test/project/file.js';
+ expect(extractDiskFilePath(winPath)).not.toBe('/Users/test/project/file.js');
+ });
+});
+
+describe('PersistentLoadResultCache', () => {
+ let mockStore: Map;
+ let persistentStore: PersistentCacheStore;
+ let tmpDir: string;
+ let file1: string;
+
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-'));
+ file1 = path.join(tmpDir, 'test.js');
+ fs.writeFileSync(file1, 'console.log("hello");');
+
+ mockStore = new Map();
+ persistentStore = {
+ async get(key: string) {
+ return mockStore.get(key);
+ },
+ async put(key: string, value: CachedLoadResultEntry) {
+ mockStore.set(key, value);
+ },
+ } as unknown as PersistentCacheStore;
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('should return undefined on L1 and L2 cache miss', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const result = await cache.get(file1);
+ expect(result).toBeUndefined();
+ });
+
+ it('should hit L2 persistent store and return cached output when dependencies are valid', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Create a second cache instance (simulating cold start)
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('console.log("hello");');
+ expect(hit?.loader).toBe('js');
+ });
+
+ it('should hit L2 persistent store and return cached output for empty file contents', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: '',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Create a second cache instance (simulating cold start)
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should preserve watchDirs when hitting L2 persistent store', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ watchDirs: [tmpDir],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.watchDirs).toEqual([tmpDir]);
+ });
+
+ it('should hit L2 persistent store for custom plugin namespaces backed by disk files', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const customPath = `sass:${file1}`;
+ const sampleResult: OnLoadResult = {
+ contents: '.btn { color: red; }',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(customPath, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(customPath);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('.btn { color: red; }');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should hit L2 persistent store for virtual modules without disk representation', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const virtualPath = 'angular:styles/component:css;0;data';
+ const sampleResult: OnLoadResult = {
+ contents: 'h1 { margin: 0; }',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(virtualPath, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(virtualPath);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('h1 { margin: 0; }');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Modify dependency file content
+ fs.writeFileSync(file1, 'console.log("world");');
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeUndefined();
+ });
+
+ it('should heal the cache entry with new metadata if content hash is still valid after mtime changes', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Retrieve the cache key from store
+ const keys = Array.from(mockStore.keys());
+ expect(keys.length).toBe(1);
+ const cacheKey = keys[0];
+
+ const initialEntry = mockStore.get(cacheKey);
+ const initialMtime = initialEntry?.watchFilesMetadata[file1].mtimeMs;
+ expect(initialMtime).toBeDefined();
+
+ // Artificially change file modification time without changing content
+ const futureTime = new Date(Date.now() + 50000);
+ fs.utimesSync(file1, futureTime, futureTime);
+
+ const statsAfter = fs.statSync(file1);
+ expect(statsAfter.mtimeMs).not.toEqual(initialMtime as number);
+
+ // Query cold cache to trigger fallback content hash check and healing
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('console.log("hello");');
+
+ // Verify metadata was healed in-place in the persistent store
+ const healedEntry = mockStore.get(cacheKey);
+ expect(healedEntry?.watchFilesMetadata[file1].mtimeMs).toEqual(statsAfter.mtimeMs);
+ });
+
+ it('should safely handle corrupted cache entries with missing watchFilesMetadata', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Corrupt entry in store by removing watchFilesMetadata
+ const keys = Array.from(mockStore.keys());
+ expect(keys.length).toBe(1);
+ const entry = mockStore.get(keys[0]);
+ expect(entry).toBeDefined();
+ if (entry) {
+ delete (entry as Partial).watchFilesMetadata;
+ }
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeUndefined();
+ });
+
+ it('should skip L2 store put if target file cannot be read from disk', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const nonExistentFile = path.join(tmpDir, 'non-existent.js');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("missing");',
+ loader: 'js',
+ watchFiles: [],
+ };
+
+ await cache.put(nonExistentFile, sampleResult);
+
+ expect(mockStore.size).toBe(0);
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
index 3d594668d44f..4f50515749df 100644
--- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
+++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
@@ -15,6 +15,8 @@ export class SqliteCacheStore implements PersistentCacheStore {
#hasStmt: StatementSync | undefined;
#setStmt: StatementSync | undefined;
#updateAccessedStmt: StatementSync | undefined;
+ readonly #pendingAccessedKeys = new Set();
+ #flushTimeout: NodeJS.Timeout | undefined;
constructor(
readonly cachePath: string,
@@ -29,6 +31,9 @@ export class SqliteCacheStore implements PersistentCacheStore {
this.#db.exec('PRAGMA auto_vacuum = FULL;');
this.#db.exec('PRAGMA journal_mode = WAL;');
this.#db.exec('PRAGMA synchronous = NORMAL;');
+ this.#db.exec('PRAGMA busy_timeout = 5000;');
+ this.#db.exec('PRAGMA temp_store = MEMORY;');
+ this.#db.exec('PRAGMA mmap_size = 268435456;');
this.#db.exec(
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
);
@@ -46,13 +51,51 @@ export class SqliteCacheStore implements PersistentCacheStore {
return this.#db;
}
+ #queueAccessUpdate(key: string): void {
+ this.#pendingAccessedKeys.add(key);
+
+ if (this.#pendingAccessedKeys.size >= 100) {
+ this.#flushAccessUpdates();
+ } else if (!this.#flushTimeout) {
+ this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 500);
+ this.#flushTimeout.unref?.();
+ }
+ }
+
+ #flushAccessUpdates(): void {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout);
+ this.#flushTimeout = undefined;
+ }
+
+ if (!this.#db || this.#pendingAccessedKeys.size === 0 || !this.#updateAccessedStmt) {
+ return;
+ }
+
+ try {
+ this.#db.exec('BEGIN IMMEDIATE TRANSACTION;');
+ for (const key of this.#pendingAccessedKeys) {
+ this.#updateAccessedStmt.run(key);
+ }
+ this.#db.exec('COMMIT;');
+ } catch {
+ try {
+ this.#db.exec('ROLLBACK;');
+ } catch {
+ // Ignore rollback errors if transaction was not active
+ }
+ } finally {
+ this.#pendingAccessedKeys.clear();
+ }
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async get(key: string): Promise {
this.#ensureDb();
const row = this.#getStmt?.get(key) as { value: string } | undefined;
if (row) {
- this.#updateAccessedStmt?.run(key);
+ this.#queueAccessUpdate(key);
try {
return JSON.parse(row.value);
@@ -72,6 +115,7 @@ export class SqliteCacheStore implements PersistentCacheStore {
async set(key: string, value: unknown): Promise {
this.#ensureDb();
+ this.#pendingAccessedKeys.delete(key);
this.#setStmt?.run(key, JSON.stringify(value));
return this;
@@ -84,6 +128,9 @@ export class SqliteCacheStore implements PersistentCacheStore {
close(): void {
if (this.#db) {
try {
+ // Flush any pending access updates in one transaction before pruning
+ this.#flushAccessUpdates();
+
// 1. Delete items older than N days
this.#db
.prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);")
@@ -103,6 +150,12 @@ export class SqliteCacheStore implements PersistentCacheStore {
} catch {
// Pruning errors should not block build success
} finally {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout);
+ this.#flushTimeout = undefined;
+ }
+ this.#pendingAccessedKeys.clear();
+
this.#getStmt = undefined;
this.#hasStmt = undefined;
this.#setStmt = undefined;
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts
new file mode 100644
index 000000000000..22397ab189b3
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { calculateHash } from '../../../utils/hash';
+import type { BundleStylesheetOptions } from './bundle-options';
+
+/**
+ * Generates a global hash based on all build options that affect stylesheet compilation.
+ *
+ * IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache
+ * entries. Any change in the values hashed below will invalidate all cached stylesheet outputs
+ * across the entire application.
+ *
+ * Included build options and their rationale:
+ * - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping.
+ * - `sourcemap`: Toggles inline/external source map generation and comment insertion.
+ * - `sourcesContent`: Toggles embedding original source content inside generated source maps.
+ * - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories.
+ * - `sassOptions`: Changes Sass compiler deprecations and behavior flags.
+ * - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild.
+ * - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output.
+ * - `outputNames`: Affects asset output filename hashing schemes.
+ * - `inlineFonts`: Controls whether external web font `@import` / `` directives are inlined.
+ * - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages.
+ * - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling.
+ * - `postcssConfig`: Path to custom PostCSS configuration file.
+ * - `tailwindConfig`: Path to Tailwind CSS configuration file.
+ * - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates.
+ *
+ * @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build`
+ * that alters generated stylesheet code or asset outputs, it MUST be added to this hash object.
+ */
+export function calculateGlobalStylesheetConfigHash(
+ options: BundleStylesheetOptions,
+ packageVersion: string = '',
+): string {
+ return calculateHash(
+ JSON.stringify({
+ optimization: options.optimization,
+ sourcemap: options.sourcemap,
+ sourcesContent: options.sourcesContent,
+ includePaths: options.includePaths,
+ sassOptions: options.sass
+ ? {
+ futureDeprecations: options.sass.futureDeprecations,
+ fatalDeprecations: options.sass.fatalDeprecations,
+ silenceDeprecations: options.sass.silenceDeprecations,
+ }
+ : undefined,
+ target: options.target,
+ publicPath: options.publicPath,
+ outputNames: options.outputNames,
+ inlineFonts: options.inlineFonts,
+ preserveSymlinks: options.preserveSymlinks,
+ externalDependencies: options.externalDependencies,
+ postcssConfig: options.postcssConfiguration?.configPath
+ ? options.postcssConfiguration.configPath
+ : '',
+ tailwindConfig: options.tailwindConfiguration?.file ? options.tailwindConfiguration.file : '',
+ packageVersion,
+ }),
+ );
+}
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts
new file mode 100644
index 000000000000..c524a2d0c36b
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { initializeHash } from '../../../utils/hash';
+import type { BundleStylesheetOptions } from './bundle-options';
+import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key';
+
+describe('Stylesheet Global Config Hash', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ const baseOptions: BundleStylesheetOptions = {
+ workspaceRoot: '/root',
+ optimization: true,
+ inlineFonts: false,
+ sourcemap: true,
+ outputNames: { bundles: 'styles', media: 'media' },
+ target: ['chrome100'],
+ cacheOptions: { enabled: true, path: '/cache', basePath: '/root' },
+ };
+
+ describe('calculateGlobalStylesheetConfigHash', () => {
+ it('should generate consistent hashes for identical options', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions }, '1.0.0');
+ expect(hash1).toBe(hash2);
+ });
+
+ it('should produce different hashes when optimization changes', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, optimization: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when sourcemap options change', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcemap: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when sourcesContent changes', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcesContent: true },
+ '1.0.0',
+ );
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcesContent: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when target browsers change', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, target: ['firefox90'] },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
index 78925f35835e..2c40e007350e 100644
--- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
@@ -427,6 +427,7 @@ async function compileString(
},
},
],
+ watchFiles: error.file && error.file !== filename ? [filename, error.file] : [filename],
};
} else {
assertIsError(error);
diff --git a/packages/angular/build/src/tools/esbuild/target.ts b/packages/angular/build/src/tools/esbuild/target.ts
new file mode 100644
index 000000000000..2925905276b7
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/target.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { coerce, compare, minVersion } from 'semver';
+
+/**
+ * Compares two target version strings.
+ *
+ * This function is used to determine the lowest version for a given browser target.
+ *
+ * @param a The first version string.
+ * @param b The second version string.
+ * @returns A negative value if `a` is lower than `b`, a positive value if `a` is higher than `b`, and 0 if they are equal.
+ */
+function compareTargetVersions(a: string, b: string): number {
+ const aVersion = coerce(a);
+ const bVersion = coerce(b);
+
+ if (!aVersion || !bVersion) {
+ return aVersion ? -1 : bVersion ? 1 : 0;
+ }
+
+ return compare(aVersion, bVersion);
+}
+
+// https://esbuild.github.io/api/#target
+const ESBUILD_SUPPORTED_BROWSERS: ReadonlySet = new Set([
+ 'chrome',
+ 'edge',
+ 'firefox',
+ 'ie',
+ 'ios',
+ 'node',
+ 'opera',
+ 'safari',
+]);
+
+/**
+ * Transform browserlists result to esbuild target.
+ *
+ * Only the lowest version for each browser is returned to avoid issues with esbuild and rolldown
+ * when multiple versions of the same target engine are specified.
+ *
+ * @see https://esbuild.github.io/api/#target
+ * @see https://github.com/evanw/esbuild/issues/4509
+ * @see https://github.com/rolldown/rolldown/issues/10633
+ */
+export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] {
+ const browsers = new Map();
+
+ for (const browser of supportedBrowsers) {
+ let [browserName, version] = browser.toLowerCase().split(' ');
+ if (!browserName || !version) {
+ continue;
+ }
+
+ // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios`
+ if (browserName === 'ios_saf') {
+ browserName = 'ios';
+ }
+
+ if (!ESBUILD_SUPPORTED_BROWSERS.has(browserName)) {
+ continue;
+ }
+
+ // browserslist uses ranges `15.2-15.3` versions but only the lowest is required
+ // to perform minimum supported feature checks. esbuild also expects a single version.
+ [version] = version.split('-');
+
+ if (browserName === 'safari' && version === 'tp') {
+ // esbuild only supports numeric versions so `TP` is converted to a high number (999) since
+ // a Technology Preview (TP) of Safari is assumed to support all currently known features.
+ version = '999';
+ } else if (!version.includes('.')) {
+ // A lone major version is considered by esbuild to include all minor versions. However,
+ // browserslist does not and is also inconsistent in its `.0` version naming. For example,
+ // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`.
+ version += '.0';
+ }
+
+ const current = browsers.get(browserName);
+ if (!current || compareTargetVersions(version, current) < 0) {
+ browsers.set(browserName, version);
+ }
+ }
+
+ return Array.from(browsers, ([browserName, version]) => browserName + version);
+}
+
+const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE';
+
+/**
+ * Transform supported Node.js versions to esbuild target.
+ *
+ * Only the lowest Node.js version is returned to avoid issues with esbuild and rolldown
+ * when multiple versions of the same target engine are specified.
+ *
+ * @see https://esbuild.github.io/api/#target
+ * @see https://github.com/evanw/esbuild/issues/4509
+ * @see https://github.com/rolldown/rolldown/issues/10633
+ */
+export function getSupportedNodeTargets(): string[] {
+ if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') {
+ // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions.
+ return [];
+ }
+
+ const parsed = minVersion(SUPPORTED_NODE_VERSIONS);
+
+ return parsed ? ['node' + parsed.version] : [];
+}
diff --git a/packages/angular/build/src/tools/esbuild/target_spec.ts b/packages/angular/build/src/tools/esbuild/target_spec.ts
new file mode 100644
index 000000000000..e5a375a1412b
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/target_spec.ts
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { getSupportedNodeTargets, transformSupportedBrowsersToTargets } from './target';
+
+describe('esbuild target', () => {
+ describe('transformSupportedBrowsersToTargets', () => {
+ it('should return the smallest version for each browser', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'chrome 122',
+ 'chrome 120',
+ 'chrome 121',
+ 'firefox 116',
+ 'firefox 115',
+ 'safari 17.0',
+ 'safari 16.4',
+ ]);
+
+ expect(targets).toEqual(['chrome120.0', 'firefox115.0', 'safari16.4']);
+ });
+
+ it('should handle version ranges and pick the lowest version', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'ios_saf 15.4',
+ 'ios_saf 15.2-15.3',
+ 'ios_saf 16.0',
+ ]);
+
+ expect(targets).toEqual(['ios15.2']);
+ });
+
+ it('should handle Safari TP (Technology Preview)', () => {
+ const targetsWithOlderSafari = transformSupportedBrowsersToTargets([
+ 'safari TP',
+ 'safari 16.4',
+ ]);
+ expect(targetsWithOlderSafari).toEqual(['safari16.4']);
+
+ const targetsWithOnlyTP = transformSupportedBrowsersToTargets(['safari TP']);
+ expect(targetsWithOnlyTP).toEqual(['safari999']);
+ });
+
+ it('should ignore browsers not supported by esbuild', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'android 4.4',
+ 'samsung 22',
+ 'kaios 2.5',
+ 'chrome 115',
+ ]);
+
+ expect(targets).toEqual(['chrome115.0']);
+ });
+
+ it('should return empty array for empty supportedBrowsers', () => {
+ const targets = transformSupportedBrowsersToTargets([]);
+ expect(targets).toEqual([]);
+ });
+
+ it('should handle malformed or incomplete browser strings gracefully', () => {
+ const targets = transformSupportedBrowsersToTargets(['chrome', 'firefox ', '']);
+ expect(targets).toEqual([]);
+ });
+
+ it('should handle single major versions by appending .0', () => {
+ const targets = transformSupportedBrowsersToTargets(['chrome 120', 'edge 120']);
+ expect(targets).toEqual(['chrome120.0', 'edge120.0']);
+ });
+ });
+
+ describe('getSupportedNodeTargets', () => {
+ it('should return empty array when node versions are not stamped', () => {
+ expect(getSupportedNodeTargets()).toEqual([]);
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts
index 6d76623f1493..e4881e184862 100644
--- a/packages/angular/build/src/tools/esbuild/utils.ts
+++ b/packages/angular/build/src/tools/esbuild/utils.ts
@@ -12,7 +12,6 @@ import { Listr } from 'listr2';
import { basename, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { brotliCompress } from 'node:zlib';
-import { coerce } from 'semver';
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { OutputMode } from '../../builders/application/schema';
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
@@ -213,7 +212,7 @@ export async function emitFilesToDisk(
writeFileCallback: (file: T) => Promise,
): Promise {
// Write files in groups of MAX_CONCURRENT_WRITES to avoid too many open files
- for (let fileIndex = 0; fileIndex < files.length; ) {
+ for (let fileIndex = 0; fileIndex < files.length;) {
const groupMax = Math.min(fileIndex + MAX_CONCURRENT_WRITES, files.length);
const actions = [];
@@ -225,71 +224,6 @@ export async function emitFilesToDisk(
}
}
-/**
- * Transform browserlists result to esbuild target.
- * @see https://esbuild.github.io/api/#target
- */
-export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] {
- const transformed: string[] = [];
-
- // https://esbuild.github.io/api/#target
- const esBuildSupportedBrowsers = new Set([
- 'chrome',
- 'edge',
- 'firefox',
- 'ie',
- 'ios',
- 'node',
- 'opera',
- 'safari',
- ]);
-
- for (const browser of supportedBrowsers) {
- let [browserName, version] = browser.toLowerCase().split(' ');
-
- // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios`
- if (browserName === 'ios_saf') {
- browserName = 'ios';
- }
-
- // browserslist uses ranges `15.2-15.3` versions but only the lowest is required
- // to perform minimum supported feature checks. esbuild also expects a single version.
- [version] = version.split('-');
-
- if (esBuildSupportedBrowsers.has(browserName)) {
- if (browserName === 'safari' && version === 'tp') {
- // esbuild only supports numeric versions so `TP` is converted to a high number (999) since
- // a Technology Preview (TP) of Safari is assumed to support all currently known features.
- version = '999';
- } else if (!version.includes('.')) {
- // A lone major version is considered by esbuild to include all minor versions. However,
- // browserslist does not and is also inconsistent in its `.0` version naming. For example,
- // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`.
- version += '.0';
- }
-
- transformed.push(browserName + version);
- }
- }
-
- return transformed;
-}
-
-const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE';
-
-/**
- * Transform supported Node.js versions to esbuild target.
- * @see https://esbuild.github.io/api/#target
- */
-export function getSupportedNodeTargets(): string[] {
- if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') {
- // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions.
- return [];
- }
-
- return SUPPORTED_NODE_VERSIONS.split('||').map((v) => 'node' + coerce(v)?.version);
-}
-
interface BuildManifest {
errors: string[];
warnings: string[];
diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts
index cf9e1d94cb87..b6e26f5c72af 100644
--- a/packages/angular/build/src/tools/esbuild/watcher.ts
+++ b/packages/angular/build/src/tools/esbuild/watcher.ts
@@ -6,7 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import WatchPack from 'watchpack';
+import type * as ParcelWatcher from '@parcel/watcher';
+import type * as Chokidar from 'chokidar';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import picomatch from 'picomatch';
+import { toPosixPath } from '../../utils/path';
export class ChangedFiles {
readonly added = new Set();
@@ -14,7 +19,7 @@ export class ChangedFiles {
readonly removed = new Set();
get all(): string[] {
- return [...this.added, ...this.modified, ...this.removed];
+ return Array.from(new Set([...this.added, ...this.modified, ...this.removed]));
}
toDebugString(): string {
@@ -34,102 +39,581 @@ export interface BuildWatcher extends AsyncIterableIterator {
close(): Promise;
}
-export function createWatcher(options?: {
+export interface WatcherOptions {
polling?: boolean;
interval?: number;
ignored?: string[];
followSymlinks?: boolean;
-}): BuildWatcher {
- const watcher = new WatchPack({
- poll: options?.polling ? (options?.interval ?? true) : false,
- ignored: options?.ignored,
- followSymlinks: options?.followSymlinks,
- aggregateTimeout: 250,
- });
- const watchedFiles = new Set();
+ cwd?: string;
+}
+
+/**
+ * Probes the filesystem at the specified target directory to determine whether it is case-sensitive.
+ */
+function isFileSystemCaseSensitive(targetDir: string = process.cwd()): boolean {
+ try {
+ const resolved = path.resolve(targetDir);
+ if (!fs.existsSync(resolved)) {
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+
+ // Invert the casing of the target directory path.
+ const altCase =
+ resolved === resolved.toLowerCase() ? resolved.toUpperCase() : resolved.toLowerCase();
+
+ // If the path contains no alphabetic characters (e.g. root '/'), invert-casing
+ // produces the exact same string. Fall back to platform-specific defaults in this case.
+ if (resolved === altCase) {
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+
+ // If both the original path and the inverted-casing path exist on disk,
+ // the filesystem is case-insensitive (returns false).
+ return !fs.existsSync(altCase);
+ } catch {
+ // If an error occurs (e.g., permission denied), default to the platform-specific
+ // behavior (case-insensitive on Windows/macOS, sensitive on Linux/Unix).
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+}
+
+/**
+ * Normalizes a file system path string to POSIX format (forward slashes '/')
+ * and strips trailing slashes (except root '/' or Windows drive root 'C:/').
+ */
+export function toPosixPathNormalized(pathString: string): string {
+ let posixPath = toPosixPath(pathString);
+ if (posixPath.length > 1 && posixPath.endsWith('/') && !/^[a-zA-Z]:\/$/.test(posixPath)) {
+ posixPath = posixPath.slice(0, -1);
+ }
+
+ return posixPath;
+}
+
+/**
+ * Returns a lookup key for set lookups and matching, lowercasing on case-insensitive file systems.
+ */
+function toLookupKey(posixPath: string, isCaseSensitive: boolean): string {
+ return isCaseSensitive ? posixPath : posixPath.toLowerCase();
+}
- const nextQueue: ((value?: ChangedFiles) => void)[] = [];
- let currentChangedFiles: ChangedFiles | undefined;
+/**
+ * Returns the parent directory of a normalized POSIX path, correctly handling Windows drive roots.
+ */
+export function getDirectoryPath(posixPath: string): string {
+ const lastSlash = posixPath.lastIndexOf('/');
+ if (lastSlash === -1) {
+ return '.';
+ }
+ const dir = posixPath.slice(0, lastSlash);
+ if (dir === '' || dir.endsWith(':')) {
+ return dir + '/';
+ }
+
+ return dir;
+}
+
+/**
+ * Determines whether a file path lookup key or any of its parent directories are present in watchedFiles.
+ */
+function isPathWatched(fileLookupKey: string, watchedFiles: Set): boolean {
+ if (watchedFiles.has(fileLookupKey)) {
+ return true;
+ }
+
+ let current = fileLookupKey;
+ while (true) {
+ const parent = getDirectoryPath(current);
+ if (parent === current) {
+ break;
+ }
+ if (watchedFiles.has(parent)) {
+ return true;
+ }
+ current = parent;
+ }
+
+ return false;
+}
+
+class WatcherQueue {
+ private readonly nextQueue: ((value?: ChangedFiles) => void)[] = [];
+ private currentChangedFiles: ChangedFiles | undefined;
+ private isClosed = false;
+ private timeoutId: NodeJS.Timeout | undefined;
+
+ addChange(type: 'added' | 'modified' | 'removed', file: string): void {
+ if (this.isClosed) {
+ return;
+ }
+
+ const changedFiles = (this.currentChangedFiles ??= new ChangedFiles());
+ changedFiles[type].add(file);
+ this.scheduleFlush();
+ }
- watcher.on('aggregated', (changes, removals) => {
- const changedFiles = currentChangedFiles ?? new ChangedFiles();
- for (const file of changes) {
- changedFiles.modified.add(file);
+ addChanges(
+ changes: ReadonlyArray<{ type: 'added' | 'modified' | 'removed'; file: string }>,
+ ): void {
+ if (this.isClosed || changes.length === 0) {
+ return;
}
- for (const file of removals) {
- changedFiles.removed.add(file);
+
+ const changedFiles = (this.currentChangedFiles ??= new ChangedFiles());
+ for (const { type, file } of changes) {
+ changedFiles[type].add(file);
}
+ this.scheduleFlush();
+ }
- const next = nextQueue.shift();
- if (next) {
- currentChangedFiles = undefined;
- next(changedFiles);
- } else {
- currentChangedFiles = changedFiles;
+ private scheduleFlush(): void {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
}
- });
+ this.timeoutId = setTimeout(() => {
+ this.timeoutId = undefined;
+ this.flush();
+ }, 250);
+ }
+
+ private flush(): void {
+ if (
+ this.currentChangedFiles &&
+ this.currentChangedFiles.all.length > 0 &&
+ this.nextQueue.length > 0
+ ) {
+ const next = this.nextQueue.shift();
+ if (next) {
+ const result = this.currentChangedFiles;
+ this.currentChangedFiles = undefined;
+ next(result);
+ }
+ }
+ }
+
+ async next(): Promise> {
+ if (
+ this.currentChangedFiles &&
+ this.currentChangedFiles.all.length > 0 &&
+ this.nextQueue.length === 0 &&
+ !this.timeoutId
+ ) {
+ const result = { value: this.currentChangedFiles };
+ this.currentChangedFiles = undefined;
+
+ return result;
+ }
+
+ if (this.isClosed) {
+ return { done: true, value: undefined as unknown as ChangedFiles };
+ }
+
+ return new Promise((resolve) => {
+ this.nextQueue.push((value) =>
+ resolve(value ? { value } : { done: true, value: undefined as unknown as ChangedFiles }),
+ );
+ });
+ }
+
+ close(): void {
+ if (this.isClosed) {
+ return;
+ }
+
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+
+ this.isClosed = true;
+ this.currentChangedFiles = undefined;
+
+ let next;
+ while ((next = this.nextQueue.shift()) !== undefined) {
+ next();
+ }
+ }
+}
+
+export async function createWatcher(options?: WatcherOptions): Promise {
+ if (options?.polling) {
+ return createChokidarWatcher(options);
+ }
+
+ try {
+ const parcelWatcher = await import('@parcel/watcher');
+
+ return await createParcelWatcher(options, parcelWatcher);
+ } catch {
+ return createChokidarWatcher(options);
+ }
+}
+
+/**
+ * Checks whether a file path is located inside a parent directory.
+ *
+ * Input Expectations:
+ * - Both `file` and `dir` must be normalized POSIX-style paths (using forward slashes '/').
+ * - Both paths must share the same casing normalization (e.g., lowercased on case-insensitive file systems).
+ */
+export function isPathInside(file: string, dir: string): boolean {
+ if (file === dir) {
+ return false;
+ }
+
+ const dirWithSlash = dir.endsWith('/') ? dir : dir + '/';
+
+ return file.startsWith(dirWithSlash);
+}
+
+class ParcelExternalManager {
+ private readonly extraSubscriptions = new Map();
+ private readonly pendingSubscriptions = new Map<
+ string,
+ Promise
+ >();
+ private readonly externalDirFiles = new Map }>();
+
+ constructor(
+ private readonly parcelWatcher: typeof ParcelWatcher,
+ private readonly options: WatcherOptions | undefined,
+ private readonly rootDirLookupKey: string,
+ private readonly handleEvents: (events: ParcelWatcher.Event[]) => void,
+ ) {}
+
+ async ensureWatched(posixPath: string, lookupKey: string): Promise {
+ if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) {
+ return;
+ }
+
+ const dirPath = getDirectoryPath(posixPath);
+ const dirKey = getDirectoryPath(lookupKey);
+ let dirEntry = this.externalDirFiles.get(dirKey);
+ if (!dirEntry) {
+ dirEntry = { dirPath, files: new Set() };
+ this.externalDirFiles.set(dirKey, dirEntry);
+ }
+ dirEntry.files.add(lookupKey);
+
+ await this.ensureDirWatched(dirPath, dirKey);
+ }
+
+ removeFile(lookupKey: string): void {
+ if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) {
+ return;
+ }
+
+ const dirKey = getDirectoryPath(lookupKey);
+ const dirEntry = this.externalDirFiles.get(dirKey);
+ if (dirEntry) {
+ dirEntry.files.delete(lookupKey);
+ if (dirEntry.files.size === 0) {
+ this.externalDirFiles.delete(dirKey);
+ const sub = this.extraSubscriptions.get(dirKey);
+ if (sub) {
+ this.extraSubscriptions.delete(dirKey);
+ sub.unsubscribe().catch(() => {});
+
+ for (const [remainingDirKey, remainingDirEntry] of this.externalDirFiles.entries()) {
+ if (!this.isCoveredByExistingExternal(remainingDirKey)) {
+ this.ensureDirWatched(remainingDirEntry.dirPath, remainingDirKey).catch(() => {});
+ }
+ }
+ }
+ }
+ }
+ }
+
+ async close(): Promise {
+ try {
+ if (this.pendingSubscriptions.size > 0) {
+ await Promise.allSettled(Array.from(this.pendingSubscriptions.values()));
+ }
+ if (this.extraSubscriptions.size > 0) {
+ await Promise.allSettled(
+ Array.from(this.extraSubscriptions.values()).map((sub) => sub.unsubscribe()),
+ );
+ }
+ } finally {
+ this.extraSubscriptions.clear();
+ this.pendingSubscriptions.clear();
+ this.externalDirFiles.clear();
+ }
+ }
+
+ private isCoveredByExistingExternal(dirLookupKey: string): boolean {
+ for (const existingDir of this.extraSubscriptions.keys()) {
+ if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) {
+ return true;
+ }
+ }
+ for (const pendingDir of this.pendingSubscriptions.keys()) {
+ if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private async ensureDirWatched(dirPath: string, dirKey: string): Promise {
+ if (this.isCoveredByExistingExternal(dirKey)) {
+ return;
+ }
+
+ const subPromise = this.parcelWatcher.subscribe(
+ dirPath,
+ (err, events) => {
+ if (!err) {
+ this.handleEvents(events);
+ }
+ },
+ {
+ ignore: this.options?.ignored,
+ },
+ );
+
+ this.pendingSubscriptions.set(dirKey, subPromise);
+
+ try {
+ const sub = await subPromise;
+ if (this.externalDirFiles.has(dirKey) && !this.isCoveredByExistingExternal(dirKey)) {
+ this.extraSubscriptions.set(dirKey, sub);
+
+ // Subsume any nested child subscriptions that are now covered by this parent subscription
+ for (const [childDir, childSub] of this.extraSubscriptions.entries()) {
+ if (childDir !== dirKey && isPathInside(childDir, dirKey)) {
+ this.extraSubscriptions.delete(childDir);
+ childSub.unsubscribe().catch(() => {});
+ }
+ }
+ } else {
+ sub.unsubscribe().catch(() => {});
+ }
+ } catch {
+ // Ignore subscription errors for missing or restricted external directories
+ } finally {
+ this.pendingSubscriptions.delete(dirKey);
+ }
+ }
+}
+
+async function createParcelWatcher(
+ options: WatcherOptions | undefined,
+ parcelWatcher: typeof ParcelWatcher,
+): Promise {
+ const watchedFiles = new Set();
+ const queue = new WatcherQueue();
+
+ const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd);
+ const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd());
+ const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
+ const initTime = Date.now();
+
+ const handleEvents = (events: ParcelWatcher.Event[]) => {
+ const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = [];
+ for (const event of events) {
+ const posixPath = toPosixPathNormalized(event.path);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!isPathWatched(lookupKey, watchedFiles)) {
+ continue;
+ }
+
+ if (event.type !== 'delete') {
+ const stat = fs.statSync(event.path, { throwIfNoEntry: false });
+ // Ignore historical events from before watcher initialization, but allow a 1000 ms window
+ // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
+ // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
+ if (stat && stat.mtimeMs < initTime - 1000) {
+ continue;
+ }
+ }
+
+ const type =
+ event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified';
+ changes.push({ type, file: event.path });
+ }
+
+ if (changes.length > 0) {
+ queue.addChanges(changes);
+ }
+ };
- return {
+ const subscription = await parcelWatcher.subscribe(
+ rootDirPosix,
+ (err, events) => {
+ if (!err) {
+ handleEvents(events);
+ }
+ },
+ {
+ ignore: options?.ignored,
+ },
+ );
+
+ const externalManager = new ParcelExternalManager(
+ parcelWatcher,
+ options,
+ rootDirLookupKey,
+ handleEvents,
+ );
+
+ const buildWatcher: BuildWatcher = {
[Symbol.asyncIterator]() {
return this;
},
- async next() {
- if (currentChangedFiles && nextQueue.length === 0) {
- const result = { value: currentChangedFiles };
- currentChangedFiles = undefined;
+ next() {
+ return queue.next();
+ },
- return result;
+ add(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ for (const file of targets) {
+ const posixPath = toPosixPathNormalized(file);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!watchedFiles.has(lookupKey)) {
+ watchedFiles.add(lookupKey);
+ void externalManager.ensureWatched(posixPath, lookupKey);
+ }
}
+ },
- return new Promise((resolve) => {
- nextQueue.push((value) => resolve(value ? { value } : { done: true, value }));
- });
+ remove(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ for (const file of targets) {
+ const posixPath = toPosixPathNormalized(file);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (watchedFiles.delete(lookupKey)) {
+ externalManager.removeFile(lookupKey);
+ }
+ }
},
- add(paths) {
- const previousSize = watchedFiles.size;
- if (typeof paths === 'string') {
- watchedFiles.add(paths);
- } else {
- for (const file of paths) {
- watchedFiles.add(file);
+ async close() {
+ try {
+ if (subscription) {
+ await subscription.unsubscribe();
}
+ await externalManager.close();
+ } finally {
+ queue.close();
}
+ },
+ };
+
+ return buildWatcher;
+}
+
+async function createChokidarWatcher(
+ options?: WatcherOptions,
+ chokidarModule?: typeof Chokidar,
+): Promise {
+ const chokidar = chokidarModule ?? (await import('chokidar'));
+ const watchedFiles = new Set();
+ const queue = new WatcherQueue();
+
+ const rootDir = options?.cwd ?? process.cwd();
+ const isCaseSensitive = isFileSystemCaseSensitive(rootDir);
+ const rootDirPosix = toPosixPathNormalized(rootDir);
+ const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
- if (previousSize !== watchedFiles.size) {
- watcher.watch({
- files: watchedFiles,
- });
+ const ignored = options?.ignored?.map((pattern) => {
+ if (/[*?[\]{}()]/.test(pattern)) {
+ const isMatch = picomatch(pattern, { dot: true });
+
+ return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
+ }
+
+ return { path: toPosixPathNormalized(pattern), recursive: true };
+ });
+
+ const watcher = chokidar.watch(rootDir, {
+ ignoreInitial: true,
+ ignored,
+ followSymlinks: options?.followSymlinks,
+ usePolling: !!options?.polling,
+ interval: options?.interval,
+ });
+ const initTime = Date.now();
+
+ const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => {
+ const posixPath = toPosixPathNormalized(rawPath);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!isPathWatched(lookupKey, watchedFiles)) {
+ return;
+ }
+
+ if (type !== 'removed') {
+ const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
+ // Ignore historical events from before watcher initialization, but allow a 1000 ms window
+ // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
+ // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
+ if (stat && stat.mtimeMs < initTime - 1000) {
+ return;
}
+ }
+
+ queue.addChange(type, rawPath);
+ };
+
+ watcher.on('add', (path) => handleEvent('added', path));
+ watcher.on('change', (path) => handleEvent('modified', path));
+ watcher.on('unlink', (path) => handleEvent('removed', path));
+
+ const buildWatcher: BuildWatcher = {
+ [Symbol.asyncIterator]() {
+ return this;
},
- remove(paths) {
- const previousSize = watchedFiles.size;
- if (typeof paths === 'string') {
- watchedFiles.delete(paths);
- } else {
- for (const file of paths) {
- watchedFiles.delete(file);
+ next() {
+ return queue.next();
+ },
+
+ add(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ const newPaths: string[] = [];
+ for (const p of targets) {
+ const posixPath = toPosixPathNormalized(p);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!watchedFiles.has(lookupKey)) {
+ watchedFiles.add(lookupKey);
+ if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
+ newPaths.push(posixPath);
+ }
}
}
+ if (newPaths.length > 0) {
+ watcher.add(newPaths);
+ }
+ },
- if (previousSize !== watchedFiles.size) {
- watcher.watch({
- files: watchedFiles,
- });
+ remove(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ const removePaths: string[] = [];
+ for (const p of targets) {
+ const posixPath = toPosixPathNormalized(p);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (watchedFiles.has(lookupKey)) {
+ watchedFiles.delete(lookupKey);
+ if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
+ removePaths.push(posixPath);
+ }
+ }
+ }
+ if (removePaths.length > 0) {
+ watcher.unwatch(removePaths);
}
},
async close() {
try {
- watcher.close();
+ await watcher.close();
} finally {
- let next;
- while ((next = nextQueue.shift()) !== undefined) {
- next();
- }
+ queue.close();
}
},
};
+
+ return buildWatcher;
}
diff --git a/packages/angular/build/src/tools/esbuild/watcher_spec.ts b/packages/angular/build/src/tools/esbuild/watcher_spec.ts
new file mode 100644
index 000000000000..2c82510a5ae5
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/watcher_spec.ts
@@ -0,0 +1,486 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { setTimeout } from 'node:timers/promises';
+import {
+ ChangedFiles,
+ createWatcher,
+ getDirectoryPath,
+ isPathInside,
+ toPosixPathNormalized,
+} from './watcher';
+
+describe('Watcher', () => {
+ describe('toPosixPathNormalized', () => {
+ it('should strip trailing slashes for standard directories', () => {
+ expect(toPosixPathNormalized('/src/app/')).toBe('/src/app');
+ expect(toPosixPathNormalized('C:/src/app/')).toBe('C:/src/app');
+ });
+
+ it('should preserve single root slash', () => {
+ expect(toPosixPathNormalized('/')).toBe('/');
+ });
+
+ it('should preserve trailing slash for Windows drive root', () => {
+ expect(toPosixPathNormalized('C:/')).toBe('C:/');
+ expect(toPosixPathNormalized('c:/')).toBe('c:/');
+ });
+ });
+
+ describe('getDirectoryPath', () => {
+ it('should return parent directory for POSIX paths', () => {
+ expect(getDirectoryPath('/src/app/main.ts')).toBe('/src/app');
+ expect(getDirectoryPath('/src/app')).toBe('/src');
+ expect(getDirectoryPath('/src')).toBe('/');
+ expect(getDirectoryPath('/')).toBe('/');
+ });
+
+ it('should correctly handle Windows drive roots', () => {
+ expect(getDirectoryPath('C:/src/app/main.ts')).toBe('C:/src/app');
+ expect(getDirectoryPath('C:/src')).toBe('C:/');
+ expect(getDirectoryPath('C:/')).toBe('C:/');
+ expect(getDirectoryPath('c:/')).toBe('c:/');
+ });
+
+ it('should return dot for relative paths without slash', () => {
+ expect(getDirectoryPath('main.ts')).toBe('.');
+ });
+ });
+
+ describe('isPathInside', () => {
+ it('should return true for a file inside a directory', () => {
+ expect(isPathInside('/src/app/main.ts', '/src/app')).toBeTrue();
+ });
+
+ it('should return false when file and dir are identical', () => {
+ expect(isPathInside('/src/app', '/src/app')).toBeFalse();
+ });
+
+ it('should return false for sibling directories with matching prefix', () => {
+ expect(isPathInside('/src/app-other/main.ts', '/src/app')).toBeFalse();
+ });
+
+ it('should handle Windows drive letters on the same drive', () => {
+ expect(isPathInside('c:/src/app/main.ts', 'c:/src/app')).toBeTrue();
+ });
+
+ it('should return false for Windows drive letters on different drives', () => {
+ expect(isPathInside('d:/src/app/main.ts', 'c:/src/app')).toBeFalse();
+ });
+
+ it('should handle root directory correctly', () => {
+ expect(isPathInside('/src/main.ts', '/')).toBeTrue();
+ });
+
+ it('should handle Windows drive root directory correctly', () => {
+ expect(isPathInside('c:/src/main.ts', 'c:/')).toBeTrue();
+ });
+ });
+
+ describe('ChangedFiles', () => {
+ it('should track added, modified, and removed files', () => {
+ const changes = new ChangedFiles();
+ changes.added.add('/src/app.component.ts');
+ changes.modified.add('/src/main.ts');
+ changes.removed.add('/src/old.ts');
+
+ expect(changes.all).toEqual(['/src/app.component.ts', '/src/main.ts', '/src/old.ts']);
+ });
+
+ it('should deduplicate files present in multiple sets in .all', () => {
+ const changes = new ChangedFiles();
+ changes.added.add('/src/main.ts');
+ changes.modified.add('/src/main.ts');
+
+ expect(changes.all).toEqual(['/src/main.ts']);
+ });
+
+ it('should format debug string correctly', () => {
+ const changes = new ChangedFiles();
+ changes.modified.add('/src/main.ts');
+
+ const debug = JSON.parse(changes.toDebugString());
+ expect(debug).toEqual({
+ added: [],
+ modified: ['/src/main.ts'],
+ removed: [],
+ });
+ });
+ });
+
+ describe('createWatcher', () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-spec-')));
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('should instantiate and close watcher without error', async () => {
+ const watcher = await createWatcher({ cwd: tempDir });
+ expect(watcher).toBeDefined();
+
+ watcher.add(path.join(tempDir, 'main.ts'));
+ watcher.remove(path.join(tempDir, 'main.ts'));
+
+ await watcher.close();
+ });
+
+ it('should support array of paths in add and remove', async () => {
+ const watcher = await createWatcher({ cwd: tempDir });
+ const file1 = path.join(tempDir, 'a.ts');
+ const file2 = path.join(tempDir, 'b.ts');
+
+ watcher.add([file1, file2]);
+ watcher.remove([file1, file2]);
+
+ await watcher.close();
+ });
+
+ it('should support polling option', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 100, cwd: tempDir });
+ expect(watcher).toBeDefined();
+
+ watcher.add(path.join(tempDir, 'main.ts'));
+ await watcher.close();
+ });
+
+ it('should emit changes when a watched file is modified (chokidar polling)', async () => {
+ const testFile = path.join(tempDir, 'test.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(testFile);
+
+ // Wait a short moment for watcher setup and mtime tick
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger change
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.length).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should preserve original path character casing in emitted changes', async () => {
+ const casedFile = path.join(tempDir, 'App.Component.ts');
+ fs.writeFileSync(casedFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(casedFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(casedFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ const emittedFiles = result.value?.all ?? [];
+ expect(emittedFiles.some((f: string) => f.includes('App.Component.ts'))).toBeTrue();
+
+ await watcher.close();
+ }, 10000);
+
+ it('should emit changes when watching a directory containing modified files', async () => {
+ const subDir = path.join(tempDir, 'sub');
+ fs.mkdirSync(subDir);
+ const testFile = path.join(subDir, 'nested.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(subDir);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.length).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should support watching paths outside cwd', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const externalFile = path.join(externalDir, 'external.txt');
+ fs.writeFileSync(externalFile, 'initial');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(externalFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(externalFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('external.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should handle adding multiple external files in the same directory concurrently', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const file1 = path.join(externalDir, 'file1.txt');
+ const file2 = path.join(externalDir, 'file2.txt');
+ fs.writeFileSync(file1, 'initial1');
+ fs.writeFileSync(file2, 'initial2');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add([file1, file2]);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ let nextPromise = iterator.next();
+ fs.writeFileSync(file1, 'updated1');
+ let result = await nextPromise;
+ expect(result.value?.all.some((f: string) => f.includes('file1.txt'))).toBeTrue();
+
+ nextPromise = iterator.next();
+ fs.writeFileSync(file2, 'updated2');
+ result = await nextPromise;
+ expect(result.value?.all.some((f: string) => f.includes('file2.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should clean up external subscriptions when all external files in a directory are removed', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const file1 = path.join(externalDir, 'file1.txt');
+ const file2 = path.join(externalDir, 'file2.txt');
+ fs.writeFileSync(file1, 'initial1');
+ fs.writeFileSync(file2, 'initial2');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add([file1, file2]);
+
+ await setTimeout(100);
+
+ // Remove files from watcher
+ watcher.remove(file1);
+ watcher.remove(file2);
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ });
+
+ it('should handle nested external directories without creating duplicate subscriptions', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const subDir = path.join(externalDir, 'sub');
+ fs.mkdirSync(subDir);
+ const parentFile = path.join(externalDir, 'parent.txt');
+ const childFile = path.join(subDir, 'child.txt');
+ fs.writeFileSync(parentFile, 'initial-parent');
+ fs.writeFileSync(childFile, 'initial-child');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(parentFile);
+ watcher.add(childFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(childFile, 'updated-child');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should subscribe to subsumed external child directory when parent external subscription is removed', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const subDir = path.join(externalDir, 'sub');
+ fs.mkdirSync(subDir);
+ const parentFile = path.join(externalDir, 'parent.txt');
+ const childFile = path.join(subDir, 'child.txt');
+ fs.writeFileSync(parentFile, 'initial-parent');
+ fs.writeFileSync(childFile, 'initial-child');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(parentFile);
+ watcher.add(childFile);
+
+ await setTimeout(100);
+
+ watcher.remove(parentFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(childFile, 'updated-child');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should signal completion on close', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ const iterator = watcher[Symbol.asyncIterator]();
+
+ const nextPromise = iterator.next();
+ await watcher.close();
+
+ const result = await nextPromise;
+ expect(result.done).toBeTrue();
+ });
+
+ it('should return done immediately if next() is called after close()', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ await watcher.close();
+
+ const result = await watcher.next();
+ expect(result.done).toBeTrue();
+ });
+
+ it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => {
+ const testFile = path.join(tempDir, 'test.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ // Small delay to ensure initial mtimeMs is strictly earlier than initTime - 1000
+ await setTimeout(1100);
+
+ // Create native @parcel/watcher (polling: false / default)
+ const watcher = await createWatcher({ cwd: tempDir });
+ watcher.add(testFile);
+
+ // Wait a short moment for native watcher setup and kernel event stream initialization
+ await setTimeout(150);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger a change after watcher initialization
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('test.txt'))).toBeTrue();
+
+ await watcher.close();
+ }, 10000);
+
+ it('should emit changes when a file is deleted and recreated with stabilization delay (@parcel/watcher)', async () => {
+ const testFile = path.join(tempDir, 'recreate.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ await setTimeout(50);
+
+ const watcher = await createWatcher({ cwd: tempDir });
+ watcher.add(testFile);
+
+ await setTimeout(150);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+
+ // Delete the file
+ fs.rmSync(testFile);
+ let result = await iterator.next();
+ expect(result.done).toBeFalsy();
+ expect(result.value?.removed.size).toBeGreaterThan(0);
+
+ // Brief stabilization delay before recreating to prevent macOS fsevents kernel driver
+ // from coalescing unlink and create into a single directory event
+ await setTimeout(150);
+
+ // Recreate the file
+ fs.writeFileSync(testFile, 'recreated');
+ result = await iterator.next();
+ expect(result.done).toBeFalsy();
+ expect(result.value?.added.size).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should ignore changes matching glob patterns in polling mode (chokidar)', async () => {
+ const ignoredDir = path.join(tempDir, 'dist');
+ fs.mkdirSync(ignoredDir);
+ const ignoredFile = path.join(ignoredDir, 'bundle.js');
+ const watchedFile = path.join(tempDir, 'src.ts');
+ fs.writeFileSync(ignoredFile, 'initial-dist');
+ fs.writeFileSync(watchedFile, 'initial-src');
+
+ const watcher = await createWatcher({
+ polling: true,
+ interval: 50,
+ cwd: tempDir,
+ ignored: [`${toPosixPathNormalized(ignoredDir)}/**`],
+ });
+
+ watcher.add(tempDir);
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger changes in ignored file and watched file
+ fs.writeFileSync(ignoredFile, 'updated-dist');
+ fs.writeFileSync(watchedFile, 'updated-src');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ const emitted = result.value?.all ?? [];
+ expect(emitted.some((f: string) => f.includes('src.ts'))).toBeTrue();
+ expect(emitted.some((f: string) => f.includes('bundle.js'))).toBeFalse();
+
+ await watcher.close();
+ }, 10000);
+ });
+});
diff --git a/packages/angular/build/src/tools/babel/plugins/adjust-static-class-members_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts
similarity index 92%
rename from packages/angular/build/src/tools/babel/plugins/adjust-static-class-members_oxc_spec.ts
rename to packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts
index af28ab66b264..7fe8c68030fc 100644
--- a/packages/angular/build/src/tools/babel/plugins/adjust-static-class-members_oxc_spec.ts
+++ b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts
@@ -998,4 +998,106 @@ describe('adjust-static-class-members oxc-transform implementation', () => {
`,
}),
);
+
+ it(
+ 'wraps adjacent class declarations without interleaving',
+ testCase({
+ input: 'class A{static s=[1]}class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent exported class declarations without interleaving',
+ testCase({
+ input: 'export class A{static s=[1]}export class B{static s=[2]}',
+ expected: `
+ export let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ export let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent default export and class declarations without interleaving',
+ testCase({
+ input: 'export default class A{static s=[1]}class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ export { A as default };
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'splits default export without interleaving with adjacent class declaration',
+ testCase({
+ input: 'export default class A{}class B{static s=[2]}',
+ expected: `
+ class A {}
+ export { A as default };
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent variable class declarations without interleaving',
+ testCase({
+ input: 'let A=class A{static s=[1]};class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ let A = class A {
+ static s = [1]
+ };
+ return A;
+ })();
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
});
diff --git a/packages/angular/build/src/tools/babel/plugins/adjust-typescript-enums_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts
similarity index 77%
rename from packages/angular/build/src/tools/babel/plugins/adjust-typescript-enums_oxc_spec.ts
rename to packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts
index fc01eecf991a..82877cb3b2e3 100644
--- a/packages/angular/build/src/tools/babel/plugins/adjust-typescript-enums_oxc_spec.ts
+++ b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts
@@ -280,4 +280,103 @@ describe('adjust-typescript-enums oxc-transform implementation', () => {
`,
}),
);
+
+ it(
+ 'handles TypeScript enums with chained exports assignment (angular-split / shared-docs pattern)',
+ testCase({
+ input: `
+ var Area;
+ (function (a1) {
+ a1[a1["areaAfter"] = 0] = "areaAfter";
+ a1[a1["preserveOtherCategoryOrder"] = 1] = "preserveOtherCategoryOrder";
+ })(Area || (Area = exports.Area = {}));
+ `,
+ expected: `
+ var Area = /*#__PURE__*/ (function (a1) {
+ a1[(a1["areaAfter"] = 0)] = "areaAfter";
+ a1[(a1["preserveOtherCategoryOrder"] = 1)] = "preserveOtherCategoryOrder";
+ return a1;
+ })(Area || (exports.Area = {}));
+ `,
+ }),
+ );
+
+ it(
+ 'handles TypeScript enums wrapped in parentheses',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ ((function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})));
+ `,
+ expected: `
+ var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'wraps Crockford-style TypeScript enum IIFE without leaving dangling parentheses',
+ testCase({
+ input: `
+ var HDirection;
+ (function (HDirection) {
+ HDirection[HDirection['Backwards'] = -1] = 'Backwards';
+ HDirection[HDirection['Forwards'] = 1] = 'Forwards';
+ }(HDirection || (HDirection = {})));
+ const nextStatement = true;
+ `,
+ expected: `
+ var HDirection = /*#__PURE__*/ (function (HDirection) {
+ HDirection[(HDirection['Backwards'] = -1)] = 'Backwards';
+ HDirection[(HDirection['Forwards'] = 1)] = 'Forwards';
+ return HDirection;
+ }(HDirection || {}));
+
+ const nextStatement = true;
+ `,
+ }),
+ );
+
+ it(
+ 'wraps TypeScript enum IIFE with multiple nested parentheses',
+ testCase({
+ input: `
+ var Foo;
+ (((function (Foo) {
+ Foo[Foo['A'] = 0] = 'A';
+ }(Foo || (Foo = {})))));
+ `,
+ expected: `
+ var Foo = /*#__PURE__*/ (function (Foo) {
+ Foo[(Foo['A'] = 0)] = 'A';
+ return Foo;
+ }(Foo || {}));
+ `,
+ }),
+ );
+
+ it(
+ 'wraps Crockford-style TypeScript enum IIFE with chained export assignments',
+ testCase({
+ input: `
+ var Foo;
+ (function (Foo) {
+ Foo[Foo['A'] = 0] = 'A';
+ }(Foo || (Foo = exports.Foo = {})));
+ `,
+ expected: `
+ var Foo = /*#__PURE__*/ (function (Foo) {
+ Foo[(Foo['A'] = 0)] = 'A';
+ return Foo;
+ }(Foo || (exports.Foo = {})));
+ `,
+ }),
+ );
});
diff --git a/packages/angular/build/src/tools/babel/plugins/elide-angular-metadata_oxc_spec.ts b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts
similarity index 100%
rename from packages/angular/build/src/tools/babel/plugins/elide-angular-metadata_oxc_spec.ts
rename to packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts
diff --git a/packages/angular/build/src/tools/babel/plugins/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts
similarity index 93%
rename from packages/angular/build/src/tools/babel/plugins/oxc-transform.ts
rename to packages/angular/build/src/tools/oxc/oxc-transform.ts
index 2c69d98cdf1a..bec14cd40385 100644
--- a/packages/angular/build/src/tools/babel/plugins/oxc-transform.ts
+++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts
@@ -6,15 +6,13 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import remapping, { type EncodedSourceMap } from '@ampproject/remapping';
+import type { DecodedSourceMap } from '@ampproject/remapping';
import type { BindingIdentifier, Class, Node } from '@oxc-project/types';
import { MagicString } from 'magic-string';
import { Visitor, parseSync } from 'oxc-parser';
-import { loadInputSourceMap } from '../../../utils/source-map';
export interface OxcTransformOptions {
sourcemap?: boolean;
- jit?: boolean;
sideEffects?: boolean;
topLevelSafeMode?: boolean;
pureAnnotate?: boolean;
@@ -261,7 +259,6 @@ export function transform(filename: string, code: string, options: OxcTransformO
const s = new MagicString(code);
const sideEffectFree = options.sideEffects === false;
- const safeAngularPackage = sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
const topLevelSafeMode = options.topLevelSafeMode ?? false;
const wrapDecorators = sideEffectFree;
const pureAnnotate = options.pureAnnotate ?? true;
@@ -414,27 +411,30 @@ export function transform(filename: string, code: string, options: OxcTransformO
continue;
}
- // 1. Remove only the trailing characters/semicolon of the expression statement
+ // 1. Remove leading/trailing characters/parentheses of the expression statement
+ s.remove(nextStatement.start, nextExpr.start);
s.remove(nextExpr.end, nextStatement.end);
- markEdited(nextExpr.end, nextStatement.end);
+ markEdited(nextStatement.start, nextStatement.end);
// 2. Add return statement inside IIFE body
s.appendRight(callee.body.end - 1, `; return ${paramName};`);
// 3. Remove `Name = ` assignment in arguments if it's a simple identifier
if (rightCallArgument.left.type === 'Identifier') {
- s.overwrite(
- arg.right.start,
- arg.right.end,
- code.substring(rightCallArgument.right.start, rightCallArgument.right.end),
+ let replacement = code.substring(
+ rightCallArgument.right.start,
+ rightCallArgument.right.end,
);
+ if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') {
+ replacement = `(${replacement})`;
+ }
+ s.overwrite(arg.right.start, arg.right.end, replacement);
markEdited(arg.right.start, arg.right.end);
}
// 4. Move IIFE to the var initializer
s.move(nextExpr.start, nextExpr.end, decl.id.end);
s.appendLeft(decl.id.end, ' = /*#__PURE__*/ ');
- markEdited(nextExpr.start, nextExpr.end);
}
}
@@ -584,25 +584,25 @@ export function transform(filename: string, code: string, options: OxcTransformO
// 1. Remove `export default `
s.overwrite(statement.start, classNode.start, '');
// 2. Wrap in IIFE
- s.appendLeft(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
- s.appendRight(
+ s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ s.appendLeft(
lastStatement.end,
`\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`,
);
} else if (isExportNamed) {
// 1. Export is kept, turn `class` into `let ClassName = IIFE`
- s.appendLeft(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
- s.appendRight(lastStatement.end, `\nreturn ${classIdName};\n})();`);
+ s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ s.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`);
} else if (isVariableClass) {
// Wrap class inside init: `/*#__PURE__*/ (() => { let ClassName = class ClassName {}; return ClassName; })()`
- s.appendLeft(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `);
+ s.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `);
const terminator = activeWrapPaths.length === 0 ? ';' : '';
const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();';
- s.appendRight(lastStatement.end, `${terminator}\nreturn ${classIdName};\n${iifeClosing}`);
+ s.appendLeft(lastStatement.end, `${terminator}\nreturn ${classIdName};\n${iifeClosing}`);
} else {
// Standard ClassDeclaration
- s.appendLeft(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
- s.appendRight(lastStatement.end, `\nreturn ${classIdName};\n})();`);
+ s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ s.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`);
}
markEdited(statement.start, lastStatement.end);
@@ -612,7 +612,7 @@ export function transform(filename: string, code: string, options: OxcTransformO
} else if (isExportDefault && !hasPotentialSideEffects) {
// Splitting default export even when not wrapped
s.overwrite(statement.start, classNode.start, '');
- s.appendRight(classNode.end, `\nexport { ${classIdName} as default };`);
+ s.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`);
markEdited(statement.start, classNode.end);
}
}
@@ -746,16 +746,10 @@ export function transform(filename: string, code: string, options: OxcTransformO
visitor.visit(program);
- let map: string | undefined;
+ let map: DecodedSourceMap | undefined;
if (options.sourcemap) {
- const rawMap = s.generateMap({ hires: true, source: filename });
- const inputMap = loadInputSourceMap(filename, code);
-
- if (inputMap) {
- map = remapping([rawMap as EncodedSourceMap, inputMap], () => null).toString();
- } else {
- map = rawMap.toString();
- }
+ const rawMap = s.generateDecodedMap({ hires: true, source: filename });
+ map = { ...rawMap, version: 3 };
}
return {
diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts
new file mode 100644
index 000000000000..daa4cf554634
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { transform } from './oxc-transform';
+
+describe('oxc-transform sourcemaps', () => {
+ it('should generate a decoded sourcemap when sourcemap option is enabled', () => {
+ const input = 'var result = new SomeClass();';
+ const result = transform('test.js', input, { sourcemap: true });
+
+ expect(result.map).toBeDefined();
+ expect(result.map?.version).toBe(3);
+ expect(result.map?.sources).toContain('test.js');
+ expect(result.map?.mappings.length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_oxc_spec.ts b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts
similarity index 100%
rename from packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_oxc_spec.ts
rename to packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts
diff --git a/packages/angular/build/src/tools/oxc/types.d.ts b/packages/angular/build/src/tools/oxc/types.d.ts
new file mode 100644
index 000000000000..aa1f40580491
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/types.d.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+declare module 'istanbul-lib-instrument' {
+ export interface Instrumenter {
+ instrumentSync(code: string, filename: string, inputSourceMap?: object): string;
+ lastSourceMap(): object | undefined;
+ }
+
+ export function createInstrumenter(options?: {
+ produceSourceMap?: boolean;
+ esModules?: boolean;
+ coverageVariable?: string;
+ }): Instrumenter;
+}
diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts
index ccc90014616d..5d2a4ddc2267 100644
--- a/packages/angular/build/src/tools/sass/rebasing-importer.ts
+++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { RawSourceMap } from '@ampproject/remapping';
+import type { DecodedSourceMap } from '@ampproject/remapping';
import { MagicString } from 'magic-string';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { basename, dirname, extname, join, relative } from 'node:path';
@@ -44,7 +44,7 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
*/
constructor(
private entryDirectory: string,
- private rebaseSourceMaps?: Map,
+ private rebaseSourceMaps?: Map,
) {}
abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null;
@@ -95,12 +95,15 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
contents = updatedContents.toString();
if (this.rebaseSourceMaps) {
// Generate an intermediate source map for the rebasing changes
- const map = updatedContents.generateMap({
+ const map = updatedContents.generateDecodedMap({
hires: 'boundary',
includeContent: true,
source: canonicalUrl.href,
});
- this.rebaseSourceMaps.set(canonicalUrl.href, map as RawSourceMap);
+ this.rebaseSourceMaps.set(canonicalUrl.href, {
+ ...map,
+ version: 3,
+ } satisfies DecodedSourceMap);
}
}
@@ -134,7 +137,7 @@ export class RelativeUrlRebasingImporter extends UrlRebasingImporter {
constructor(
entryDirectory: string,
private directoryCache = new Map(),
- rebaseSourceMaps?: Map,
+ rebaseSourceMaps?: Map,
) {
super(entryDirectory, rebaseSourceMaps);
}
@@ -322,7 +325,7 @@ export class ModuleUrlRebasingImporter extends RelativeUrlRebasingImporter {
constructor(
entryDirectory: string,
directoryCache: Map,
- rebaseSourceMaps: Map | undefined,
+ rebaseSourceMaps: Map | undefined,
private finder: (specifier: string, options: CanonicalizeContext) => URL | null,
) {
super(entryDirectory, directoryCache, rebaseSourceMaps);
@@ -349,7 +352,7 @@ export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter {
constructor(
entryDirectory: string,
directoryCache: Map,
- rebaseSourceMaps: Map | undefined,
+ rebaseSourceMaps: Map | undefined,
private loadPaths: Iterable,
) {
super(entryDirectory, directoryCache, rebaseSourceMaps);
diff --git a/packages/angular/build/src/tools/sass/worker.ts b/packages/angular/build/src/tools/sass/worker.ts
index 1a2e1184892f..e4167a3d1c69 100644
--- a/packages/angular/build/src/tools/sass/worker.ts
+++ b/packages/angular/build/src/tools/sass/worker.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import mergeSourceMaps, { RawSourceMap } from '@ampproject/remapping';
+import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { MessagePort, receiveMessageOnPort } from 'node:worker_threads';
@@ -89,7 +89,7 @@ export default async function renderSassStylesheet(
let warnings: SerializableWarningMessage[] | undefined;
try {
const directoryCache = new Map();
- const rebaseSourceMaps = options.sourceMap ? new Map() : undefined;
+ const rebaseSourceMaps = options.sourceMap ? new Map() : undefined;
if (importerChannel) {
// When a custom importer function is present, the importer request must be proxied
// back to the main thread where it can be executed.
diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
index cf98614bc55b..02f54756eaff 100644
--- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
@@ -7,7 +7,6 @@
*/
import { lookup as lookupMimeType } from 'mrmime';
-import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import type { ServerResponse } from 'node:http';
import { extname } from 'node:path';
@@ -15,6 +14,7 @@ import type { Connect, ViteDevServer } from 'vite' with {
'resolution-mode': 'import',
};
import { ResultFile } from '../../../builders/application/results';
+import { calculateHash } from '../../../utils/hash';
import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils';
export interface ComponentStyleRecord {
@@ -50,7 +50,7 @@ export function createAngularAssetsMiddleware(
// This is a workaround to serve extensionless, CSS, JS and TS files without Vite transformations.
if (!extension || JS_TS_REGEXP.test(extension) || CSS_PREPROCESSOR_REGEXP.test(extension)) {
const contents = readFileSync(asset.source);
- const etag = `W/${createHash('sha256').update(contents).digest('hex')}`;
+ const etag = `W/${calculateHash(contents)}`;
if (checkAndHandleEtag(req, res, etag)) {
return;
}
@@ -238,7 +238,7 @@ export function createBuildAssetsMiddleware(
const contents =
outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.inputPath);
- const etag = `W/${createHash('sha256').update(contents).digest('hex')}`;
+ const etag = `W/${calculateHash(contents)}`;
if (checkAndHandleEtag(req, res, etag)) {
return;
}
diff --git a/packages/angular/build/src/tools/vite/utils.ts b/packages/angular/build/src/tools/vite/utils.ts
index f2b54d403334..dc7094b8ffe4 100644
--- a/packages/angular/build/src/tools/vite/utils.ts
+++ b/packages/angular/build/src/tools/vite/utils.ts
@@ -48,6 +48,7 @@ export type RolldownLoaderOption = Exclude<
>['moduleTypes'];
export function getDepOptimizationConfig({
+ target,
disabled,
exclude,
include,
@@ -56,6 +57,7 @@ export function getDepOptimizationConfig({
thirdPartySourcemaps,
define = {},
}: {
+ target: string[];
disabled: boolean;
exclude: string[];
include: string[];
@@ -73,6 +75,7 @@ export function getDepOptimizationConfig({
noDiscovery: disabled,
rolldownOptions: {
transform: {
+ target,
define,
},
moduleTypes: loader,
diff --git a/packages/angular/build/src/utils/bundle-calculator.ts b/packages/angular/build/src/utils/bundle-calculator.ts
index 3349a8a40830..71a75dafde02 100644
--- a/packages/angular/build/src/utils/bundle-calculator.ts
+++ b/packages/angular/build/src/utils/bundle-calculator.ts
@@ -152,6 +152,8 @@ function calculateSizes(budget: BudgetEntry, stats: BudgetStats): Size[] {
}
abstract class Calculator {
+ private assetMap?: ReadonlyMap;
+
constructor(
protected budget: BudgetEntry,
protected chunks: BudgetChunk[],
@@ -167,15 +169,24 @@ abstract class Calculator {
return 0;
}
+ if (!this.assetMap) {
+ const map = new Map();
+ for (const asset of this.assets) {
+ map.set(asset.name, asset.size);
+ }
+ this.assetMap = map;
+ }
+ const assetMap = this.assetMap;
+
return chunk.files
.filter((file) => !file.endsWith('.map'))
.map((file) => {
- const asset = this.assets.find((asset) => asset.name === file);
- if (!asset) {
+ const assetSize = assetMap.get(file);
+ if (assetSize === undefined) {
throw new Error(`Could not find asset for file: ${file}`);
}
- return asset.size;
+ return assetSize;
})
.reduce((l, r) => l + r, 0);
}
diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts
index 075865345a6c..206467e929a0 100644
--- a/packages/angular/build/src/utils/environment-options.ts
+++ b/packages/angular/build/src/utils/environment-options.ts
@@ -194,6 +194,13 @@ export const useComponentTemplateHmr = parseTristate(process.env['NG_HMR_TEMPLAT
*/
export const usePartialSsrBuild = parseTristate(process.env['NG_BUILD_PARTIAL_SSR']) === true;
+/**
+ * When `NG_BUILD_BABEL_LINKER` is enabled (`1` or `true`), the Babel-based
+ * Angular Linker (`@angular/compiler-cli/linker/babel`) will be used instead of the
+ * default OXC in-place linker.
+ */
+export const useBabelLinker = parseTristate(process.env['NG_BUILD_BABEL_LINKER']) === true;
+
const bazelBinDirectory = process.env['BAZEL_BINDIR'];
const bazelExecRoot = process.env['JS_BINARY__EXECROOT'];
diff --git a/packages/angular/build/src/utils/hash.ts b/packages/angular/build/src/utils/hash.ts
new file mode 100644
index 000000000000..fdc5565afbb1
--- /dev/null
+++ b/packages/angular/build/src/utils/hash.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import assert from 'node:assert';
+import type { XXHashAPI } from 'xxhash-wasm';
+
+let xxhashInstance: XXHashAPI | undefined;
+let xxhashPromise: Promise | undefined;
+
+/**
+ * Initializes the xxHash WASM instance early to ensure synchronous hashing uses xxHash.
+ */
+export async function initializeHash(): Promise {
+ if (xxhashInstance) {
+ return;
+ }
+
+ xxhashPromise ??= import('xxhash-wasm').then((m) => m.default());
+ xxhashInstance = await xxhashPromise;
+}
+
+function getXxhash(): XXHashAPI {
+ assert(
+ xxhashInstance,
+ 'Hash utility must be initialized by awaiting `initializeHash()` before use.',
+ );
+
+ return xxhashInstance;
+}
+
+/**
+ * Calculates a fast 64-bit non-cryptographic hash of the provided content.
+ * Suitable for cache keys, ETags, and change detection.
+ */
+export function calculateHash(data: string | Uint8Array): string {
+ const instance = getXxhash();
+
+ if (typeof data === 'string') {
+ return instance.h64ToString(data);
+ }
+
+ return instance.h64Raw(data).toString(16).padStart(16, '0');
+}
+
+export interface ContentHasher {
+ update(data: string | Uint8Array): ContentHasher;
+ digest(): string;
+}
+
+/**
+ * Creates a streaming 64-bit non-cryptographic content hasher.
+ */
+export function createContentHash(): ContentHasher {
+ const instance = getXxhash();
+ const hasher = instance.create64();
+
+ const contentHasher: ContentHasher = {
+ update(data: string | Uint8Array): ContentHasher {
+ hasher.update(data);
+
+ return contentHasher;
+ },
+ digest(): string {
+ return hasher.digest().toString(16).padStart(16, '0');
+ },
+ };
+
+ return contentHasher;
+}
diff --git a/packages/angular/build/src/utils/hash_spec.ts b/packages/angular/build/src/utils/hash_spec.ts
new file mode 100644
index 000000000000..77fcbc909403
--- /dev/null
+++ b/packages/angular/build/src/utils/hash_spec.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import { calculateHash, createContentHash, initializeHash } from './hash';
+
+describe('hash utility', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ it('should calculate identical 64-bit hex hash for string and Buffer with same content', () => {
+ const text = 'export const message = "hello world";';
+ const buffer = Buffer.from(text, 'utf-8');
+
+ const stringHash = calculateHash(text);
+ const bufferHash = calculateHash(buffer);
+
+ expect(typeof stringHash).toBe('string');
+ expect(stringHash.length).toBe(16);
+ expect(stringHash).toBe(bufferHash);
+ });
+
+ it('should calculate different hashes for different contents', () => {
+ const hash1 = calculateHash('const a = 1;');
+ const hash2 = calculateHash('const a = 2;');
+
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should support streaming multi-part hashing matching combined single-shot hash', () => {
+ const part1 = 'header: ';
+ const part2 = 'body content: ';
+ const part3 = 'footer';
+
+ const hasher = createContentHash();
+ hasher.update(part1);
+ hasher.update(part2);
+ hasher.update(Buffer.from(part3, 'utf-8'));
+ const streamingHash = hasher.digest();
+
+ const singleShotHash = calculateHash(part1 + part2 + part3);
+
+ expect(streamingHash.length).toBe(16);
+ expect(streamingHash).toBe(singleShotHash);
+ });
+
+ it('should handle Uint8Array chunks in streaming hasher', () => {
+ const hasher = createContentHash();
+ hasher.update(new Uint8Array([1, 2, 3, 4])).update('some-string');
+ const digest = hasher.digest();
+
+ expect(typeof digest).toBe('string');
+ expect(digest.length).toBe(16);
+ });
+});
diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts
index 822683bef03d..6a288622d053 100644
--- a/packages/angular/build/src/utils/i18n-options.ts
+++ b/packages/angular/build/src/utils/i18n-options.ts
@@ -28,6 +28,7 @@ export interface I18nOptions {
flatOutput?: boolean;
readonly shouldInline: boolean;
hasDefinedSourceLocale?: boolean;
+ localizeVersion?: string;
}
function normalizeTranslationFileOption(
diff --git a/packages/angular/build/src/utils/index-file/auto-csp.ts b/packages/angular/build/src/utils/index-file/auto-csp.ts
index 0e1dfe3ed916..df33f0766607 100644
--- a/packages/angular/build/src/utils/index-file/auto-csp.ts
+++ b/packages/angular/build/src/utils/index-file/auto-csp.ts
@@ -22,6 +22,8 @@ interface SrcScriptTag {
type?: string;
async: boolean;
defer: boolean;
+ integrity?: string;
+ crossOrigin?: string;
}
/**
@@ -106,11 +108,14 @@ export async function autoCsp(html: string, unsafeEval = false): Promise
// If there are any interesting attributes, note them down.
const scriptType = getScriptAttributeValue(tag, 'type');
if (shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) {
+ const crossOrigin = getScriptAttributeValue(tag, 'crossorigin');
scriptContent.push({
src: src,
type: scriptType,
async: getScriptAttributeValue(tag, 'async') !== undefined,
defer: getScriptAttributeValue(tag, 'defer') !== undefined,
+ integrity: getScriptAttributeValue(tag, 'integrity'),
+ crossOrigin: crossOrigin === '' ? 'anonymous' : crossOrigin,
});
return; // Skip writing my script tag until we've read it all.
@@ -267,36 +272,52 @@ function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false)
const srcAttr = encodeURI(s.src).replaceAll("'", "\\'");
// Can only be 'module' or a JS MIME type or an empty string.
const typeAttr = s.type ? "'" + s.type + "'" : "''";
- const asyncAttr = s.async ? 'true' : 'false';
- const deferAttr = s.defer ? 'true' : 'false';
+ const asyncAttr = !!s.async;
+ const deferAttr = !!s.defer;
+ const integrityAttr = JSON.stringify(s.integrity ?? null).replaceAll('<', '\\u003c');
+ const crossOriginAttr = JSON.stringify(s.crossOrigin ?? null).replaceAll('<', '\\u003c');
- return `['${srcAttr}', ${typeAttr}, ${asyncAttr}, ${deferAttr}]`;
+ return `['${srcAttr}', ${typeAttr}, ${asyncAttr}, ${deferAttr}, ${integrityAttr}, ${crossOriginAttr}]`;
})
.join();
return enableTrustedTypes
- ? `
- var scripts = [${srcListFormatted}];
- var policy = self.trustedTypes && self.trustedTypes.createPolicy ?
+ ? `(() => {
+ const scripts = [${srcListFormatted}];
+ const policy = self.trustedTypes && self.trustedTypes.createPolicy ?
self.trustedTypes.createPolicy('angular#auto-csp', {createScriptURL: function(u) {
- return scripts.includes(u) ? u : null;
+ return scripts.some(function(s) { return s[0] === u; }) ? u : null;
}}) : { createScriptURL: function(u) { return u; } };
scripts.forEach(function(scriptUrl) {
- var s = document.createElement('script');
+ const s = document.createElement('script');
s.src = policy.createScriptURL(scriptUrl[0]);
s.type = scriptUrl[1];
- s.async = !!scriptUrl[2];
- s.defer = !!scriptUrl[3];
+ s.async = scriptUrl[2];
+ s.defer = scriptUrl[3];
+ if (scriptUrl[4]) {
+ s.integrity = scriptUrl[4];
+ }
+ if (scriptUrl[5]) {
+ s.crossOrigin = scriptUrl[5];
+ }
document.lastElementChild.appendChild(s);
- });\n`
- : `
- var scripts = [${srcListFormatted}];
+ });
+})();\n`
+ : `(() => {
+ const scripts = [${srcListFormatted}];
scripts.forEach(function(scriptUrl) {
- var s = document.createElement('script');
+ const s = document.createElement('script');
s.src = scriptUrl[0];
s.type = scriptUrl[1];
- s.async = !!scriptUrl[2];
- s.defer = !!scriptUrl[3];
+ s.async = scriptUrl[2];
+ s.defer = scriptUrl[3];
+ if (scriptUrl[4]) {
+ s.integrity = scriptUrl[4];
+ }
+ if (scriptUrl[5]) {
+ s.crossOrigin = scriptUrl[5];
+ }
document.lastElementChild.appendChild(s);
- });\n`;
+ });
+})();\n`;
}
diff --git a/packages/angular/build/src/utils/index-file/auto-csp_spec.ts b/packages/angular/build/src/utils/index-file/auto-csp_spec.ts
index efbd338233ad..29e9bff68074 100644
--- a/packages/angular/build/src/utils/index-file/auto-csp_spec.ts
+++ b/packages/angular/build/src/utils/index-file/auto-csp_spec.ts
@@ -58,7 +58,7 @@ describe('auto-csp', () => {
const csps = getCsps(result);
expect(csps).toHaveSize(1);
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
- expect(result).toContain(`var scripts = [['./main.js', '', false, false]];`);
+ expect(result).toContain(`const scripts = [['./main.js', '', false, false, null, null]];`);
});
it('should rewrite a single source script in place', async () => {
@@ -78,7 +78,7 @@ describe('auto-csp', () => {
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
// Our loader script appears after the HTML text content.
expect(result).toMatch(
- /Some text<\/div>\s*`);
@@ -166,12 +166,12 @@ describe('auto-csp', () => {
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
expect(result).toMatch(
// eslint-disable-next-line max-len
- /console.log\('foo'\);<\/script>\s*
+ Some text
+