diff --git a/.bazelversion b/.bazelversion index 3b6825376add..eec6dacbd482 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -8.8.0 +8.8.1 diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index 30585fa707fd..715bb9bf0c56 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -18,6 +18,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@183403ae13b785698eaf13c819dda55b9fed430b # main + - uses: angular/dev-infra/github-actions/branch-manager@a948cdfda182b725e3abad445b2e6264508db3ed # main with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fad471f1244..a8dffcf5aac3 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Run CLI E2E tests @@ -208,13 +208,13 @@ 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # main with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Start Sauce Connect @@ -245,11 +245,11 @@ jobs: CIRCLE_BRANCH: ${{ github.ref_name }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - run: pnpm admin snapshots --verbose env: SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index bb05d9b3dbfe..7ac22449d1f1 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -16,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@183403ae13b785698eaf13c819dda55b9fed430b # main + - uses: angular/dev-infra/github-actions/labeling/pull-request@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + - uses: angular/dev-infra/github-actions/post-approval-changes@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + - uses: angular/dev-infra/github-actions/labeling/issue@a948cdfda182b725e3abad445b2e6264508db3ed # 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 504f7e5136ad..4ebe4ce4c1bd 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -22,7 +22,7 @@ jobs: workflows: ${{ steps.workflows.outputs.workflows }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - id: workflows @@ -40,9 +40,9 @@ jobs: workflow: ${{ fromJSON(needs.list.outputs.workflows) }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # 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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8b2baafbd3b4..ae130af4ec3d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,9 +34,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/linting/licenses@a948cdfda182b725e3abad445b2e6264508db3ed # main build: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/setup@a948cdfda182b725e3abad445b2e6264508db3ed # main - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main + uses: angular/dev-infra/github-actions/bazel/configure-remote@a948cdfda182b725e3abad445b2e6264508db3ed # 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/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..1e4c0c60d35f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +--- +trigger: always_on +--- + +This is the source code for the Angular CLI and related build tooling. This guide outlines standard practices for AI agents working in this repository. + +## Environment + +- Use `pnpm` for package management. +- Use `pnpm bazel test //target` to run tests. + +## Key Documentation + +- [Developer Guide](docs/DEVELOPER.md): definitive guide for building, debugging, and running test targets. +- [Contributing Guide](CONTRIBUTING.md): general contribution workflows and guidelines. +- [Commit Guidelines](CONTRIBUTING.md#commit): format for commit messages and PR titles. + +## Building + +- Make a local build of all packages: + ```shell + pnpm build --local + ``` + +## Coding Practices + +- **Imports:** + - Always use the `node:` protocol for Node.js built-in imports (e.g., `node:fs`, `node:path`, `node:assert`). + - Prefer named imports (e.g., `import { mkdtemp } from 'node:fs'`) or default imports (`import fs from 'node:fs'`) instead of namespace imports (`import * as fs`). + - Use type-only imports (`import type { ... }`) when importing types to avoid runtime side-effects. +- **Classes:** + - Prefer ECMAScript private fields (`#field`) over TypeScript `private` keywords for encapsulated state. + +## Testing + +- **Temporary Directories (`TEST_TMPDIR`):** + - Tests in this repository only run in Bazel. **ALWAYS** use `process.env['TEST_TMPDIR']` and assert that it is set: + ```ts + import assert from 'node:assert'; + import { mkdtemp } from 'node:fs/promises'; + import { join } from 'node:path'; + + describe('...', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-test-')); + }); + }); + ``` + - **NEVER** use or fallback to `os.tmpdir()`. Bazel executes tests in hermetic sandboxes and sets `TEST_TMPDIR` to an isolated, sandboxed directory. Using `os.tmpdir()` can cause sandboxing failures, permission errors, or file leakage outside the Bazel sandbox. +- **Unit Tests:** + - Run all unit tests: `pnpm bazel test //packages/...` + - Run a specific test target: `pnpm bazel test //packages/angular/build:test` + - Query test targets: `pnpm bazel query "tests(//packages/...)"` + - Focus specific tests when debugging: use `fdescribe()` and `fit()`. NEVER commit focused tests to the repository. + - Run tests without sharding when isolating or debugging: use `--config=no-sharding` with a specific test target (e.g., `pnpm bazel test //packages/angular/build:test --config=no-sharding`). + This disables test sharding (`--test_sharding_strategy=disabled`) and flaky test retries (`--flaky_test_attempts=1`). + This is especially useful when isolating test runs or debugging with focused tests (`fit`/`fdescribe`) to avoid empty shard failures and unnecessary re-runs. + Do not use this flag when running broad test suites (such as `//packages/...`), as executing tests without sharding takes significantly longer. +- **End-to-End Tests:** + - Run subset of E2E tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter=""` + +## Pull Requests + +- Use the `gh` CLI (GitHub CLI) for creating and managing pull requests. +- **Fixup Commits:** + - When addressing review feedback, **ALWAYS** use fixup commits (`git commit --fixup `) instead of amending existing commits. This preserves commit history during review and allows reviewers to easily see incremental changes. + - Only use fixup commits for changes that directly belong to the target commit. Unrelated changes must be made in a separate commit with their own commit message, not as a fixup commit. + - Fixup commits are automatically squashed when merging with `pnpm ng-dev pr merge` or optionally when rebasing with `pnpm ng-dev pr rebase `. +- Use `pnpm ng-dev pr` commands: + - `pnpm ng-dev pr rebase `: Rebase a PR branch on its target branch and optionally squash fixup commits. + - `pnpm ng-dev pr merge `: Merge an approved PR into its targeted branches. diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ae51f6dbe9..fb8ea2b1ceeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ - + -# 22.2.0-next.7 (2026-09-10) +# 22.2.0 (2026-09-23) ## Deprecations @@ -10,31 +10,87 @@ Disabling code splitting is no longer necessary with Vitest 5, as the issue where shared-module exports were uninitialized in class-field initializers under JSDOM has been resolved. +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [41555dfb3](https://github.com/angular/angular-cli/commit/41555dfb3b71d08cdfe2853bf2cbeca5b6942f67) | feat | add `--root` command line option to `mcp` command | +| [6e7f809a7](https://github.com/angular/angular-cli/commit/6e7f809a7d66e4b8d260a1c00297812412a926f1) | perf | skip eager yargs help message formatting during command execution | + ### @angular/build -| Commit | Type | Description | -| ---------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -| [fe2f180fd0](https://github.com/angular/angular-cli/commit/fe2f180fd0c2fa02706353ca6a034d235feb6c59) | fix | add automatic corruption recovery in SQLite cache store | -| [675f1f5274](https://github.com/angular/angular-cli/commit/675f1f5274ff853f153832f7adc69273fa63f0cd) | fix | avoid pure comment pattern in oxc transform | -| [d396324b4e](https://github.com/angular/angular-cli/commit/d396324b4eb3b16640526a64365d953642bc7ca1) | fix | default preloadInitial to false when serviceWorker is enabled | -| [8504cfffcf](https://github.com/angular/angular-cli/commit/8504cfffcfb943fcd8fda62507e4b236e4bf429a) | fix | disable root config extension for inline test project | -| [5d21812aae](https://github.com/angular/angular-cli/commit/5d21812aaedc57cbd9378a99d9747f80c413cdc3) | fix | disable strictPort when port 0 is used in dev-server | -| [54d712f579](https://github.com/angular/angular-cli/commit/54d712f57904d2f612eec27a0507ea298b40915e) | fix | ensure parent directory exists in SQLite cache store | -| [b423bc39cd](https://github.com/angular/angular-cli/commit/b423bc39cd59c73ae79402c6cccdc0ecf1d7aaa1) | fix | use chokidar watcher when followSymlinks is enabled | -| [5a0d226b7c](https://github.com/angular/angular-cli/commit/5a0d226b7ccb945edd234fa0c839994f4876e2ee) | perf | avoid full JSON parsing when updating sourcemap ignore list | -| [482f6d8aab](https://github.com/angular/angular-cli/commit/482f6d8aab289fbd6dc5f9aecdcf7b8e1b6cac4f) | perf | decouple diagnostic type checking from build start | -| [f1afa603b4](https://github.com/angular/angular-cli/commit/f1afa603b48566e45dfb8d95930375a71f0bfa0b) | perf | read rendered module length once per module in chunk optimizer ([#34045](https://github.com/angular/angular-cli/pull/34045)) | -| [0ba4d8e29c](https://github.com/angular/angular-cli/commit/0ba4d8e29c0fb9abe6b07432edfab9e39066ca29) | perf | reduce watcher debounce latency for faster incremental rebuilds | -| [4d11a1334f](https://github.com/angular/angular-cli/commit/4d11a1334ff5fabfe5338cc3de5ce4f45f6afa5f) | perf | selectively compress large entries in SQLite cache store | -| [0a137f98d0](https://github.com/angular/angular-cli/commit/0a137f98d035f3dbd9a5daf1b6999246f73610c8) | perf | share sass directory and resolution caches across stylesheets | -| [d11a663e77](https://github.com/angular/angular-cli/commit/d11a663e77dad3dc9dbb6336e782f23b8a6f595c) | perf | verify cache metadata before reading file and eliminate sqlite read-locks | -| [66bd6bd1ea](https://github.com/angular/angular-cli/commit/66bd6bd1eab41484ed5db38f5545e80de4090b34) | refactor | deprecate splitting option in unit-test builder | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------- | +| [ad8271320](https://github.com/angular/angular-cli/commit/ad8271320f67c2b1502e446f1e3cb0a05444d04f) | feat | migrate Angular Linker to oxc-parser and magic-string | +| [175273931](https://github.com/angular/angular-cli/commit/175273931ebb0cf08bf62dd1694dbde5ff5229b6) | feat | Support splitting browser and server stats jsonfiles for easier consumption | +| [fe2f180fd](https://github.com/angular/angular-cli/commit/fe2f180fd0c2fa02706353ca6a034d235feb6c59) | fix | add automatic corruption recovery in SQLite cache store | +| [675f1f527](https://github.com/angular/angular-cli/commit/675f1f5274ff853f153832f7adc69273fa63f0cd) | fix | avoid pure comment pattern in oxc transform | +| [d396324b4](https://github.com/angular/angular-cli/commit/d396324b4eb3b16640526a64365d953642bc7ca1) | fix | default preloadInitial to false when serviceWorker is enabled | +| [7e5510e46](https://github.com/angular/angular-cli/commit/7e5510e464eb423d8757ae6f058f73fc17249d81) | fix | disable linker internal source mapping in OXC linker | +| [8504cfffc](https://github.com/angular/angular-cli/commit/8504cfffcfb943fcd8fda62507e4b236e4bf429a) | fix | disable root config extension for inline test project | +| [ee3041325](https://github.com/angular/angular-cli/commit/ee3041325cd2400c26e239e64e14a0e314ab6031) | fix | ensure chokidar watcher is ready before returning | +| [54d712f57](https://github.com/angular/angular-cli/commit/54d712f57904d2f612eec27a0507ea298b40915e) | fix | ensure parent directory exists in SQLite cache store | +| [80acd6236](https://github.com/angular/angular-cli/commit/80acd6236eac6b1b07543583717e94cbd1de1180) | fix | execute setup file hooks for each spec file when coverage is enabled | +| [978351af7](https://github.com/angular/angular-cli/commit/978351af71889d4786a18fec9e193eb5b16af7dd) | fix | include inline component stylesheets in referenced watch files | +| [f47f77f5f](https://github.com/angular/angular-cli/commit/f47f77f5f6db5cda1493652f813c98c26f172ea9) | fix | only compile included test files in unit-test builder | +| [72dc9677c](https://github.com/angular/angular-cli/commit/72dc9677cf743243d6cb22d2b81782022f71356e) | fix | pass load cache to compiler plugin and escape extension regex in polyfills | +| [701bde481](https://github.com/angular/angular-cli/commit/701bde4818439de0e49c99d57dc749b0dd22c4dd) | fix | preserve duplicate bundle filenames across output file types | +| [e4111ae81](https://github.com/angular/angular-cli/commit/e4111ae8180f3cd5800f248c23010844aa1730ad) | fix | prevent memory leaks in parallel compilation and caches | +| [31c045639](https://github.com/angular/angular-cli/commit/31c045639e8bfd114cbd552d83cc70a968fe7f81) | fix | prevent stale bundler caching and correctly resolve load cache | +| [bad31421c](https://github.com/angular/angular-cli/commit/bad31421cbf23339d4fbd389a91d198df405697d) | fix | prevent syntax corruption in linker and oxc transform | +| [0d9851600](https://github.com/angular/angular-cli/commit/0d9851600ca618ccabfc3143a9c552a5448d6eec) | fix | recursively ignore output and cache paths in watch mode | +| [040bc21ec](https://github.com/angular/angular-cli/commit/040bc21ec3d4fa1bdfa8513206f463d89e20d9a1) | fix | scope Sass package resolution caching for stylesheets in node_modules | +| [1fc273279](https://github.com/angular/angular-cli/commit/1fc2732795069ad521645b06a94e5df3a55c3d9c) | fix | support parenthesized expressions in oxc linker | +| [b423bc39c](https://github.com/angular/angular-cli/commit/b423bc39cd59c73ae79402c6cccdc0ecf1d7aaa1) | fix | use chokidar watcher when followSymlinks is enabled | +| [87551ad5c](https://github.com/angular/angular-cli/commit/87551ad5c53f90cbdc68fdb5e47007b9436c7f75) | perf | avoid encoding intermediate source maps before remapping | +| [6ee559ca9](https://github.com/angular/angular-cli/commit/6ee559ca9d209fb4c53de2aaf9918d10e5f062a1) | perf | bypass worker dispatch for files without transform candidates | +| [0589edd9c](https://github.com/angular/angular-cli/commit/0589edd9cdc493ecf34a160cc7b8491618b59f72) | perf | consolidate component stylesheet bundling with shared load result cache | +| [482f6d8aa](https://github.com/angular/angular-cli/commit/482f6d8aab289fbd6dc5f9aecdcf7b8e1b6cac4f) | perf | decouple diagnostic type checking from build start | +| [a877aa7eb](https://github.com/angular/angular-cli/commit/a877aa7ebdbf919d6bc17e6ec0920ca702db0db4) | perf | implement SharedArrayBuffer translation dictionaries for zero-copy worker access | +| [f1cc95943](https://github.com/angular/angular-cli/commit/f1cc959439d8ca11a45d693520c34dd94e0b6548) | perf | implement sliding-window batching and worker translation eviction | +| [d97c8857c](https://github.com/angular/angular-cli/commit/d97c8857c18da02fcc6c69a0ea652cc706c5c9b6) | perf | key Sass package resolutions without containing URL qualification | +| [5cd20e141](https://github.com/angular/angular-cli/commit/5cd20e141ad176c87e4002da2d4ee3755f97cd3e) | perf | only reset Sass worker pool caches when stylesheet files change | +| [1c00edce0](https://github.com/angular/angular-cli/commit/1c00edce02bdd0201546417d71518f78bfb89d34) | perf | optimize sourcemap stripping and loading with buffer fast path | +| [0ba4d8e29](https://github.com/angular/angular-cli/commit/0ba4d8e29c0fb9abe6b07432edfab9e39066ca29) | perf | reduce watcher debounce latency for faster incremental rebuilds | +| [a6ef9cfbe](https://github.com/angular/angular-cli/commit/a6ef9cfbeace725d58c0f7f65640ef6de9b39c33) | perf | replace watchpack with @parcel/watcher and chokidar | +| [4d11a1334](https://github.com/angular/angular-cli/commit/4d11a1334ff5fabfe5338cc3de5ce4f45f6afa5f) | perf | selectively compress large entries in SQLite cache store | +| [0a137f98d](https://github.com/angular/angular-cli/commit/0a137f98d035f3dbd9a5daf1b6999246f73610c8) | perf | share sass directory and resolution caches across stylesheets | +| [f4dad31cc](https://github.com/angular/angular-cli/commit/f4dad31cce41bc3e8ed8767256d9e50a712c3a72) | perf | unify Oxc linking and optimization AST traversal passes | +| [2b85529e8](https://github.com/angular/angular-cli/commit/2b85529e8290c8c916539b5a0336b4efdb68578c) | perf | use iterative post-order AST traversal for OXC transforms | +| [ecbcd87b8](https://github.com/angular/angular-cli/commit/ecbcd87b8857225e4df3df7896b3236d63553f23) | perf | use native sass-embedded compiler daemon | +| [ce83ac0e8](https://github.com/angular/angular-cli/commit/ce83ac0e82981efd9408e07a35315e3cef9e1a36) | perf | use size-weighted task heuristics in i18n inliner | +| [d11a663e7](https://github.com/angular/angular-cli/commit/d11a663e77dad3dc9dbb6336e782f23b8a6f595c) | perf | verify cache metadata before reading file and eliminate sqlite read-locks | +| [66bd6bd1e](https://github.com/angular/angular-cli/commit/66bd6bd1eab41484ed5db38f5545e80de4090b34) | refactor | deprecate splitting option in unit-test builder | ### @angular/ssr -| Commit | Type | Description | -| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | -| [23e3d44a7f](https://github.com/angular/angular-cli/commit/23e3d44a7f051cd3bb67700b8d8407f73b7aa7f3) | perf | pre-compile critical CSS plans at build time | +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [23e3d44a7](https://github.com/angular/angular-cli/commit/23e3d44a7f051cd3bb67700b8d8407f73b7aa7f3) | perf | pre-compile critical CSS plans at build time | + + + + + +# 22.1.9 (2026-09-23) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [b94dabdb7](https://github.com/angular/angular-cli/commit/b94dabdb744ff1fe72b79bfbda5f5c8749c58f71) | fix | update @types/node to a version vitest 5 accepts | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [5aaad0a13](https://github.com/angular/angular-cli/commit/5aaad0a13a3dc65b07347dde313ac9ae2aa013ac) | fix | name the unknown option in a schema validation error | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [bfec8bac4](https://github.com/angular/angular-cli/commit/bfec8bac4d6185efa768b4387ed9e8926979954f) | fix | support standard JavaScript MIME types and case insensitivity in auto-CSP | @@ -76,40 +132,6 @@ - - -# 22.2.0-next.6 (2026-09-02) - -### @angular/cli - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | -| [b37c93329](https://github.com/angular/angular-cli/commit/b37c933299dac37e394d9f4fe0d019052db427a9) | fix | isolate temporary package installations from parent Yarn workspace | - -### @angular/build - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | -| [4b8c62a8f](https://github.com/angular/angular-cli/commit/4b8c62a8f1a1d312ef2e1e0df6b7642bf1db5c5f) | fix | add splitting option to unit-test builder | -| [51565d4bc](https://github.com/angular/angular-cli/commit/51565d4bcbd4667c23c607d878f08301afc4c5e3) | fix | enable code splitting for vitest browser tests | -| [5b850fdfc](https://github.com/angular/angular-cli/commit/5b850fdfc7534d33db7ab4a7f1e32e143462eec7) | fix | fail build and exclude routes when prerendering fails | -| [dfa7f03a9](https://github.com/angular/angular-cli/commit/dfa7f03a93b5b24b949aea354bc40f0a91e9582d) | fix | keep dev-server Vite cache worktree-local | -| [135662d9a](https://github.com/angular/angular-cli/commit/135662d9a1de85b42292b26b547160eaca71e5da) | fix | preserve binary values in the SQLite cache store | -| [701bde481](https://github.com/angular/angular-cli/commit/701bde4818439de0e49c99d57dc749b0dd22c4dd) | fix | preserve duplicate bundle filenames across output file types | -| [6ee559ca9](https://github.com/angular/angular-cli/commit/6ee559ca9d209fb4c53de2aaf9918d10e5f062a1) | perf | bypass worker dispatch for files without transform candidates | -| [2b85529e8](https://github.com/angular/angular-cli/commit/2b85529e8290c8c916539b5a0336b4efdb68578c) | perf | use iterative post-order AST traversal for OXC transforms | -| [ecbcd87b8](https://github.com/angular/angular-cli/commit/ecbcd87b8857225e4df3df7896b3236d63553f23) | perf | use native sass-embedded compiler daemon | -| [ce83ac0e8](https://github.com/angular/angular-cli/commit/ce83ac0e82981efd9408e07a35315e3cef9e1a36) | perf | use size-weighted task heuristics in i18n inliner | - -### @angular/ssr - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | -| [4fa81d403](https://github.com/angular/angular-cli/commit/4fa81d403c93a7480301e58630203cb028aebc69) | fix | decode pathname when constructing server asset path | -| [bb72145f9](https://github.com/angular/angular-cli/commit/bb72145f9ab45aee29f523236b3a25cd0813a841) | fix | ensure public directory containment in CommonEngine | - - - # 22.1.7 (2026-09-02) @@ -175,30 +197,6 @@ - - -# 22.2.0-next.5 (2026-08-26) - -### @angular/build - -| Commit | Type | Description | -| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | -| [4d0be683b7](https://github.com/angular/angular-cli/commit/4d0be683b780a9a1629dc8f9cddad49153bddabf) | fix | ignore side-effect annotations in unit test builds | -| [e4111ae818](https://github.com/angular/angular-cli/commit/e4111ae8180f3cd5800f248c23010844aa1730ad) | fix | prevent memory leaks in parallel compilation and caches | -| [62999cabf4](https://github.com/angular/angular-cli/commit/62999cabf4d65cf1060598150a77494e147fcc2a) | perf | batch prerender routes and share in-memory server bundles | -| [0589edd9cd](https://github.com/angular/angular-cli/commit/0589edd9cdc493ecf34a160cc7b8491618b59f72) | perf | consolidate component stylesheet bundling with shared load result cache | -| [a877aa7ebd](https://github.com/angular/angular-cli/commit/a877aa7ebdbf919d6bc17e6ec0920ca702db0db4) | perf | implement SharedArrayBuffer translation dictionaries for zero-copy worker access | -| [f1cc959439](https://github.com/angular/angular-cli/commit/f1cc959439d8ca11a45d693520c34dd94e0b6548) | perf | implement sliding-window batching and worker translation eviction | -| [f4dad31cce](https://github.com/angular/angular-cli/commit/f4dad31cce41bc3e8ed8767256d9e50a712c3a72) | perf | unify Oxc linking and optimization AST traversal passes | - -### @angular/ssr - -| Commit | Type | Description | -| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | -| [f8576e3c8b](https://github.com/angular/angular-cli/commit/f8576e3c8bfe3490b1b0b85832b489593a4e43ac) | fix | abort web request signal when node request is aborted | - - - # 22.1.6 (2026-08-26) @@ -242,36 +240,6 @@ - - -# 22.2.0-next.4 (2026-08-19) - -### @angular/cli - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | -| [34e1e0bb5](https://github.com/angular/angular-cli/commit/34e1e0bb5d5ccf374f51a1b241158f6f2ea0dd42) | fix | enforce MCP roots in get_best_practices tool | -| [2b060630c](https://github.com/angular/angular-cli/commit/2b060630c66ce688b7ecbe51d282955869e5d4b0) | fix | handle errors from isAllowedWorkspacePath in best-practices tool | -| [24fd8fce2](https://github.com/angular/angular-cli/commit/24fd8fce2a3ff035acdc5f3e036eb4ba21f71a76) | fix | throw on out-of-roots workspace in best-practices tool | - -### @schematics/angular - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | -| [ce1b60f89](https://github.com/angular/angular-cli/commit/ce1b60f89699c3a76496a0489e5f2b9e4fe62429) | fix | transform fail() to expect.fail() in refactor-jasmine-vitest | - -### @angular/build - -| Commit | Type | Description | -| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | -| [175273931](https://github.com/angular/angular-cli/commit/175273931ebb0cf08bf62dd1694dbde5ff5229b6) | feat | Support splitting browser and server stats jsonfiles for easier consumption | -| [1ee0beca7](https://github.com/angular/angular-cli/commit/1ee0beca7b243cc4c06eb42b25d216d7b256b097) | fix | correct misleading error message for top-level await | -| [0ffe2d27f](https://github.com/angular/angular-cli/commit/0ffe2d27f256a5755809fa4788e4adb97c65b806) | fix | disable code splitting for unit test builds | -| [50994d76e](https://github.com/angular/angular-cli/commit/50994d76e59a4b632f1782c51cd60f80c9cd50fb) | fix | preserve integrity and crossorigin in autoCsp loader | -| [d6fd24320](https://github.com/angular/angular-cli/commit/d6fd243207ad1c3e242b8592698afa986320cc6c) | perf | traverse AST with iterative post-order walker in i18n inliner | - - - # 22.1.5 (2026-08-19) @@ -301,54 +269,6 @@ - - -# 22.2.0-next.3 (2026-08-13) - -### @angular/cli - -| 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 | -| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | -| [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.4 (2026-08-13) @@ -430,54 +350,6 @@ - - -# 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) @@ -581,24 +453,6 @@ - - -# 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 | -| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | -| [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) diff --git a/MODULE.bazel b/MODULE.bazel index 470c5e258947..53a88fa03302 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,30 +10,30 @@ bazel_dep(name = "rules_nodejs", version = "6.7.5") bazel_dep(name = "aspect_rules_js", version = "3.4.1") bazel_dep(name = "aspect_rules_ts", version = "3.10.1") 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 = "rules_cc", version = "0.2.25") +bazel_dep(name = "jq.bzl", version = "0.6.2") bazel_dep(name = "bazel_lib", version = "3.7.2") bazel_dep(name = "bazel_skylib", version = "1.9.2") -bazel_dep(name = "aspect_rules_esbuild", version = "0.27.0") +bazel_dep(name = "aspect_rules_esbuild", version = "0.27.1") bazel_dep(name = "aspect_rules_jasmine", version = "2.0.4") bazel_dep(name = "rules_angular") git_override( module_name = "rules_angular", - commit = "5fa856469c642490a6e381a05d7f58f60fad1913", + commit = "c40fd63ad915b6c159a7eba93079757024ad2844", remote = "https://github.com/angular/rules_angular.git", ) bazel_dep(name = "devinfra") git_override( module_name = "devinfra", - commit = "183403ae13b785698eaf13c819dda55b9fed430b", + commit = "a948cdfda182b725e3abad445b2e6264508db3ed", remote = "https://github.com/angular/dev-infra.git", ) bazel_dep(name = "rules_browsers") git_override( module_name = "rules_browsers", - commit = "c37398d63f5e990d618d02f4c6fb2fefe40f1a79", + commit = "a73e3fb4819202ed84bad97e19039e7591b7e87a", remote = "https://github.com/angular/rules_browsers.git", ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 43e07b4b4f31..9e584bcf5df4 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -10,6 +10,9 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", + "https://bcr.bazel.build/modules/apple_support/2.8.0/MODULE.bazel": "c45f5176057092afba0be6c48a9ea02101666802f8be536947e099e94757f8ed", + "https://bcr.bazel.build/modules/apple_support/2.8.0/source.json": "b7f6d87669c206adaa7d82cc44733349c63e341b80a0e049496ebd91d78c3472", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.0.0/MODULE.bazel": "e118477db5c49419a88d78ebc7a2c2cea9d49600fe0f490c1903324a2c16ecd9", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989", @@ -18,8 +21,8 @@ "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b", "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.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_esbuild/0.27.1/MODULE.bazel": "99c3978959edd9892e4b513831b218a13cc84a0215bfe1b972a1e3a771c4670e", + "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.1/source.json": "20f515102cbcd0835d90bb3976b193eb58c2a0c534093c85cfd3bf6f06dba748", "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", @@ -40,6 +43,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", @@ -85,8 +89,8 @@ "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.4.0/MODULE.bazel": "a7b39b37589f2b0dad53fd6c1ccaabbdb290330caa920d7ef3e6aad068cd4ab2", - "https://bcr.bazel.build/modules/jq.bzl/0.6.1/MODULE.bazel": "f30c46e0a08a9f7566a8bf60a43d48abea960cd7f57b315b01e2762f1537eb52", - "https://bcr.bazel.build/modules/jq.bzl/0.6.1/source.json": "9ca9e2f90baa6a5bb0a49626ed9528554ec83165adf47b39792673ecc7feda22", + "https://bcr.bazel.build/modules/jq.bzl/0.6.2/MODULE.bazel": "e9c82f9b1e720d4ab0e232d32c05f0f4d0f92a8e8bb6a0da8f7cd27823b93e05", + "https://bcr.bazel.build/modules/jq.bzl/0.6.2/source.json": "e36f8ed173a6ca6e627f9d659ae504733d5a991859805661e4aacd34d8ae0639", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", @@ -100,6 +104,7 @@ "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2", "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344", @@ -131,8 +136,9 @@ "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.22/MODULE.bazel": "94df4328edef9e44d38de5e73b037cd348e75e7ae55f4e21bf07878c41a31ebb", - "https://bcr.bazel.build/modules/rules_cc/0.2.22/source.json": "b2d6d6f9c332ce269ad75b89c6f3168d809a66173c9040210fb9bcc733ab42fa", + "https://bcr.bazel.build/modules/rules_cc/0.2.20/MODULE.bazel": "f5c07bce5ddcb99be21a0812ff5aadb439e688b7449c6542152363b2fd859c1a", + "https://bcr.bazel.build/modules/rules_cc/0.2.25/MODULE.bazel": "4a3d4f3606d3b3190be495dbc497acca552807d1d5540661ef0d1658472882e3", + "https://bcr.bazel.build/modules/rules_cc/0.2.25/source.json": "8df28a9a97b878fe45f0a4f4adf019869dda51a60ee2f6e15a0c79656a25058d", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", @@ -203,8 +209,8 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", - "https://bcr.bazel.build/modules/tar.bzl/0.10.8/MODULE.bazel": "443884cabe241f640cfef256b1de2ccb116752895532900a4be9500e1124323f", - "https://bcr.bazel.build/modules/tar.bzl/0.10.8/source.json": "4173be64b38e471d92d2eb139a6496311de6de75e710f04edce78c43122c5419", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/MODULE.bazel": "4636f5f6a7c34d3d0653634ed95a34560426d548c5301c6c5924c885ef737b46", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/source.json": "30a5cf0a8281adce53ab1c5a4291e88b2c161e2df1feb6cba4ee50556f1748d7", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", @@ -222,8 +228,8 @@ "moduleExtensions": { "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": { "general": { - "bzlTransitiveDigest": "ivnb7fuZRAghsUscNSqk/cyy9pTpUKwbi8xOKhgxNbo=", - "usagesDigest": "LZ71sshnfqI8R/HeWwBTwawxIa7KnitayBl2hYrqPo4=", + "bzlTransitiveDigest": "TR1SYff4zvqpfgaxFTA+Vur5yBatUujlJlLbnl5C6EM=", + "usagesDigest": "ZqBbSnzAi8ZyBL3PXnAGtDMIdC2pcuieJWMY3/94lvA=", "recordedInputs": [ "REPO_MAPPING:aspect_rules_esbuild+,aspect_rules_js aspect_rules_js+", "REPO_MAPPING:aspect_rules_esbuild+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", @@ -355,7 +361,7 @@ "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { "general": { "bzlTransitiveDigest": "zFgbaCUuPzH6bIE7CBHBVCqjWSJ78/WNXisdzXaKSnU=", - "usagesDigest": "2cLMn8ZcqY/6j/zH70TrPob+ENshTw9h48kUHxn1cUM=", + "usagesDigest": "B4C1UNcAOoRiMjmkXh3VFW7fY2YULy8OZA7RjfEM2UQ=", "recordedInputs": [ "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+", @@ -368,11 +374,11 @@ "deps": { "aspect_rules_js": "3.4.1", "aspect_rules_ts": "3.10.1", - "aspect_rules_esbuild": "0.27.0", + "aspect_rules_esbuild": "0.27.1", "aspect_rules_jasmine": "2.0.4", "aspect_tools_telemetry": "0.5.1" }, - "last_notice": 0 + "last_notice": 2 } } }, @@ -433,16 +439,16 @@ }, "@@rules_browsers+//browsers:extensions.bzl%browsers": { "general": { - "bzlTransitiveDigest": "eNfcpVF2YJFb8xQfN64Y9PQgMrXYK0ju3uglArt8IO8=", + "bzlTransitiveDigest": "jwGo+QiVY+i/AnJLUkEJ+vaplFYfygil2SJt8C7kM5o=", "usagesDigest": "FmXYJVoVJlnfUU8x8gObSvu4qWcco/9Faw61aC/wBF0=", "recordedInputs": [], "generatedRepoSpecs": { "rules_browsers_chrome_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "ad2e73e1e42c0831930dfd02d4aa8653a5f23f60b0376a5d76ddf8ecd7a41e4b", + "sha256": "11d48f3e2ee11fc2a53a230eb364b2becc44c1b67a7aa4683edb7cb8a950b6ab", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/linux64/chrome-headless-shell-linux64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/linux64/chrome-headless-shell-linux64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-linux64/chrome-headless-shell" @@ -458,9 +464,9 @@ "rules_browsers_chrome_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "d66b730ed9760ae88b83aca1fa89351ac027ede430be4f5283fabd156198762d", + "sha256": "42c60eb1793fe76aabf19664b03c299170e5de53baa23ae72578f063dc9b2614", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/mac-x64/chrome-headless-shell-mac-x64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-x64/chrome-headless-shell-mac-x64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-x64/chrome-headless-shell" @@ -476,9 +482,9 @@ "rules_browsers_chrome_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "44c784c59af60ef62233b6100820ebb8b158e7d105b96c684818f007d6576848", + "sha256": "4afc1dbaeea03ad545b434c0f9eba50af326923fc265bc25927b3b4c7d9ca377", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/mac-arm64/chrome-headless-shell-mac-arm64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-arm64/chrome-headless-shell-mac-arm64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-arm64/chrome-headless-shell" @@ -494,9 +500,9 @@ "rules_browsers_chrome_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "996537a6c3d31bb5c75ef328af5d4827b5f92c4a36ff7ea250e437eadf46eac0", + "sha256": "9de8381d4ca9c9f0471fd1cce15e44146328c3e11f55fec43cd667ae1a5bd482", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/win64/chrome-headless-shell-win64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/win64/chrome-headless-shell-win64.zip" ], "named_files": { "CHROME-HEADLESS-SHELL": "chrome-headless-shell-win64/chrome-headless-shell.exe" @@ -512,9 +518,9 @@ "rules_browsers_chromedriver_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "f1db8caff52251dec46117a2f518a74cd1e4a2abadcbecc0d5bdd49e42175afa", + "sha256": "29be812bf4392619d06dfb5716d2bf9d72a278304670a594e3977d4c2cce7502", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/linux64/chromedriver-linux64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/linux64/chromedriver-linux64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-linux64/chromedriver" @@ -528,9 +534,9 @@ "rules_browsers_chromedriver_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "5062675bc868f3f6a1e4850cab72b736a5c17f98f01d610736b8d0a6e009bf54", + "sha256": "d4dc141aadd9525e67c92fc78ec65e2926226c436eca7d90e39cdc813726513d", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/mac-x64/chromedriver-mac-x64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-x64/chromedriver-mac-x64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-mac-x64/chromedriver" @@ -544,9 +550,9 @@ "rules_browsers_chromedriver_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "04f07a2a13e969f120dbea61eef291dc4e20617a6d424dec20e90f903da0263b", + "sha256": "984056c6d3dd189569cc89affd28564a9de949d58297279fc23a045c8fc53080", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/mac-arm64/chromedriver-mac-arm64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/mac-arm64/chromedriver-mac-arm64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-mac-arm64/chromedriver" @@ -560,9 +566,9 @@ "rules_browsers_chromedriver_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "7701074890112ed3b780af26104cb9f57ce68e229ca3076bfade6a1af93730d7", + "sha256": "a5bcbd013b64e6a15acc84c164fe974d0f1a58d4e061fba79d24b31cced64d88", "urls": [ - "https://storage.googleapis.com/chrome-for-testing-public/155.0.8043.0/win64/chromedriver-win64.zip" + "https://storage.googleapis.com/chrome-for-testing-public/156.0.8060.2/win64/chromedriver-win64.zip" ], "named_files": { "CHROMEDRIVER": "chromedriver-win64/chromedriver.exe" @@ -576,9 +582,9 @@ "rules_browsers_firefox_linux": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "fd9ec3f5f113d0825ca0bd1ab3c0756fbc40241034eefce6743be953bcca7473", + "sha256": "1d44cd02351c307c3e19061ea2a4d18a30f236e6be862b94f2282564afdb0167", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/155.0/linux-x86_64/en-US/firefox-155.0.tar.xz" + "https://archive.mozilla.org/pub/firefox/releases/156.0/linux-x86_64/en-US/firefox-156.0.tar.xz" ], "named_files": { "FIREFOX": "firefox/firefox" @@ -592,9 +598,9 @@ "rules_browsers_firefox_mac": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "3ae135f2023cf0c6cbce3bb757e06564ff8148b1266f682b8eccff0993a1a9f5", + "sha256": "fecc46103039ca4a77ecb2b2ef7b03892f922e13db0782f8570c512b2e458515", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/155.0/mac/en-US/Firefox%20155.0.dmg" + "https://archive.mozilla.org/pub/firefox/releases/156.0/mac/en-US/Firefox%20156.0.dmg" ], "named_files": { "FIREFOX": "Firefox.app/Contents/MacOS/firefox" @@ -608,9 +614,9 @@ "rules_browsers_firefox_mac_arm": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "3ae135f2023cf0c6cbce3bb757e06564ff8148b1266f682b8eccff0993a1a9f5", + "sha256": "fecc46103039ca4a77ecb2b2ef7b03892f922e13db0782f8570c512b2e458515", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/155.0/mac/en-US/Firefox%20155.0.dmg" + "https://archive.mozilla.org/pub/firefox/releases/156.0/mac/en-US/Firefox%20156.0.dmg" ], "named_files": { "FIREFOX": "Firefox.app/Contents/MacOS/firefox" @@ -624,9 +630,9 @@ "rules_browsers_firefox_win64": { "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", "attributes": { - "sha256": "2718d4cc4b2cf089aa8ac370111652bc763838e1d2ed6d635d465fc17ab6327c", + "sha256": "86a027237f7408e8a4d18637c57d487455a704cbb2466a32d0d14c2207d2699a", "urls": [ - "https://archive.mozilla.org/pub/firefox/releases/155.0/win64/en-US/Firefox%20Setup%20155.0.exe" + "https://archive.mozilla.org/pub/firefox/releases/156.0/win64/en-US/Firefox%20Setup%20156.0.exe" ], "named_files": { "FIREFOX": "core/firefox.exe" diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 35b07adf1e89..ddb8341e662b 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -7,9 +7,9 @@ To get started locally, follow these instructions: 1. If you haven't done it already, [make a fork of this repo](https://github.com/angular/angular-cli/fork). 2. If you are on Windows, see [the extra steps needed for contributing on Windows](#windows) 3. Clone to your local computer using `git`. -4. Make sure that you have Node `v20.19.0` or higher installed. See instructions [here](https://nodejs.org/en/download/). +4. Make sure that you have Node `v22.22.3` or higher installed (see `engines` in `package.json`). See instructions [here](https://nodejs.org/en/download/). 5. Install `pnpm`. - - You can install pnpm by running `npm i -g pnpm@9`. + - You can install pnpm by running `npm i -g pnpm@12`. - See detailed instructions [here](https://pnpm.io/installation). 6. Run `pnpm install` from the root of your clone of this project to install dependencies. @@ -78,7 +78,7 @@ There are two different test suites which can be run locally: ### Unit tests - Run all tests: `pnpm bazel test //packages/...` -- Run a subset of the tests, use the full Bazel target example: `pnpm bazel test //packages/schematics/angular:angular_test` +- Run a subset of the tests, use the full Bazel target example: `pnpm bazel test //packages/schematics/angular:test` - For a complete list of test targets use the following Bazel query: `pnpm bazel query "tests(//packages/...)"` When debugging a specific test, change `describe()` or `it()` to `fdescribe()` @@ -166,7 +166,7 @@ You can use the Chrome Devtools to process it. To do so: Adding a package to this repository means running two separate commands: -1. `schematics devkit:package PACKAGE_NAME`. This will update the `.monorepo` file, and create the +1. `schematics devkit:package PACKAGE_NAME`. This will update the `.monorepo.json` file, and create the base files for the new package (package.json, src/index, etc). 1. `pnpm admin templates`. This will update the README and all other template files that might have changed when adding a new package. diff --git a/docs/process/bazel.md b/docs/process/bazel.md index 86542047745e..458d634ba9b6 100644 --- a/docs/process/bazel.md +++ b/docs/process/bazel.md @@ -65,7 +65,7 @@ the reruns of tests that have been marked as `flaky`. The `.bazelrc` includes a config for running tests with remote debugging enabled: ```sh -pnpm bazel test --config=debug //packages/angular/cli:angular-cli_test +pnpm bazel test --config=debug //packages/angular/cli:test # Also disable reruns of failing tests that were marked as flaky: -pnpm bazel test --config=debug --config=no-sharding //packages/angular/cli:angular-cli_test +pnpm bazel test --config=debug --config=no-sharding //packages/angular/cli:test ``` diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index c9d83bc6fd9b..b08d70c07e64 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -236,6 +236,7 @@ export type UnitTestBuilderOptions = { isolate?: boolean; listTests?: boolean; outputFile?: string; + polyfills?: string[]; progress?: boolean; providersFile?: string; quiet?: boolean; diff --git a/goldens/public-api/angular_devkit/architect/node/index.api.md b/goldens/public-api/angular_devkit/architect/node/index.api.md new file mode 100644 index 000000000000..059423576f71 --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/node/index.api.md @@ -0,0 +1,73 @@ +## API Report File for "@angular-devkit/architect_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; +import { workspaces } from '@angular-devkit/core'; + +declare namespace jobs { + export { + NodeModuleJobRegistry + } +} +export { jobs } + +// @public (undocumented) +class NodeModuleJobRegistry implements jobs_2.Registry { + get(name: jobs_2.JobName): Observable | null>; + // (undocumented) + protected _resolve(name: string): string | null; +} + +// @public (undocumented) +export type NodeModulesBuilderInfo = BuilderInfo & { + import: string; +}; + +// @public (undocumented) +export interface WorkspaceHost { + // (undocumented) + getBuilderName(project: string, target: string): Promise; + // (undocumented) + getDefaultConfigurationName(project: string, target: string): Promise; + // (undocumented) + getMetadata(project: string): Promise; + // (undocumented) + getOptions(project: string, target: string, configuration?: string): Promise; + // (undocumented) + hasTarget(project: string, target: string): Promise; +} + +// @public (undocumented) +export class WorkspaceNodeModulesArchitectHost implements ArchitectHost { + constructor(workspaceHost: WorkspaceHost, _root: string); + constructor(workspace: workspaces.WorkspaceDefinition, _root: string); + // (undocumented) + getBuilderNameForTarget(target: Target_2): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target_2): Promise; + // (undocumented) + getProjectMetadata(target: Target_2 | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: NodeModulesBuilderInfo): Promise; + resolveBuilder(builderStr: string, basePath?: string, seenBuilders?: Set): Promise; + // (undocumented) + protected _root: string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/testing/index.api.md b/goldens/public-api/angular_devkit/architect/testing/index.api.md new file mode 100644 index 000000000000..23cc6fe8cd7a --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/testing/index.api.md @@ -0,0 +1,76 @@ +## API Report File for "@angular-devkit/architect_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Stats } from 'node:fs'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class TestingArchitectHost implements ArchitectHost { + constructor(workspaceRoot?: string, currentDirectory?: string, _backendHost?: ArchitectHost | null); + // (undocumented) + addBuilder(builderName: string, builder: Builder, description?: string, optionSchema?: json.schema.JsonSchema): void; + // (undocumented) + addBuilderFromPackage(packageName: string): Promise; + // (undocumented) + addTarget(target: Target, builderName: string, options?: json.JsonObject): void; + // (undocumented) + currentDirectory: string; + // (undocumented) + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(target: Target | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: BuilderInfo): Promise; + resolveBuilder(builderName: string): Promise; + // (undocumented) + workspaceRoot: string; +} + +// @public @deprecated (undocumented) +export class TestProjectHost extends NodeJsSyncHost { + constructor(_templateRoot: Path); + // (undocumented) + appendToFile(path: string, str: string): void; + // (undocumented) + copyFile(from: string, to: string): void; + // (undocumented) + fileMatchExists(dir: string, regex: RegExp): PathFragment | undefined; + // (undocumented) + initialize(): Observable; + // (undocumented) + replaceInFile(path: string, match: RegExp | string, replacement: string): void; + // (undocumented) + restore(): Observable; + // (undocumented) + root(): Path; + // (undocumented) + scopedSync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _templateRoot: Path; + // (undocumented) + writeMultipleFiles(files: { + [path: string]: string | ArrayBufferLike | Buffer; + }): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json index 3e0dc743f7ea..292acd7f1c92 100644 --- a/modules/testing/builder/package.json +++ b/modules/testing/builder/package.json @@ -7,8 +7,8 @@ "@vitest/coverage-v8": "5.0.1", "browser-sync": "3.0.4", "istanbul-lib-instrument": "6.0.3", - "jsdom": "30.0.1", - "ng-packagr": "22.2.0-next.5", + "jsdom": "30.1.1", + "ng-packagr": "22.3.0-next.1", "rxjs": "7.8.2", "vitest": "5.0.1" } diff --git a/modules/testing/builder/src/builder-harness.ts b/modules/testing/builder/src/builder-harness.ts index 67b5f760d148..570939cecd7f 100644 --- a/modules/testing/builder/src/builder-harness.ts +++ b/modules/testing/builder/src/builder-harness.ts @@ -111,7 +111,7 @@ export class BuilderHarness { } } - private resolvePath(path: string): string { + resolvePath(path: string): string { return join(getSystemPath(this.host.root()), path); } diff --git a/package.json b/package.json index 69fd5f4f7f42..d6fd3fb94684 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/devkit-repo", - "version": "22.2.0-next.7", + "version": "22.3.0-next.0", "private": true, "description": "Software Development Kit for Angular", "keywords": [ @@ -15,6 +15,7 @@ "bazel": "bazelisk", "test": "bazel test //packages/...", "build": "pnpm --silent admin build", + "benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark", "build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/", "lint": "eslint --cache --max-warnings=0", "templates": "pnpm --silent admin templates", @@ -28,12 +29,12 @@ "type": "git", "url": "git+https://github.com/angular/angular-cli.git" }, - "packageManager": "pnpm@12.4.2", + "packageManager": "pnpm@12.6.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": "12.4.2" + "pnpm": "12.6.0" }, "author": "Angular Authors", "license": "MIT", @@ -42,24 +43,24 @@ }, "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular/compiler-cli": "22.2.0-next.5", + "@angular/compiler-cli": "22.2.0-rc.0", "typescript": "6.0.3" }, "devDependencies": { - "@angular/animations": "22.2.0-next.5", - "@angular/cdk": "22.2.0-next.4", - "@angular/common": "22.2.0-next.5", - "@angular/compiler": "22.2.0-next.5", - "@angular/core": "22.2.0-next.5", - "@angular/forms": "22.2.0-next.5", - "@angular/localize": "22.2.0-next.5", - "@angular/material": "22.2.0-next.4", - "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#c3d7ed184d71d390d3eacff85f4b2b3f16b82660", - "@angular/platform-browser": "22.2.0-next.5", - "@angular/platform-server": "22.2.0-next.5", - "@angular/router": "22.2.0-next.5", - "@angular/service-worker": "22.2.0-next.5", - "@babel/core": "8.0.5", + "@angular/animations": "22.2.0-rc.0", + "@angular/cdk": "22.2.0-rc.0", + "@angular/common": "22.2.0-rc.0", + "@angular/compiler": "22.2.0-rc.0", + "@angular/core": "22.2.0-rc.0", + "@angular/forms": "22.2.0-rc.0", + "@angular/localize": "22.2.0-rc.0", + "@angular/material": "22.2.0-rc.0", + "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#df3931fca45cce0a2fa74c9ccc5876c571ef8406", + "@angular/platform-browser": "22.2.0-rc.0", + "@angular/platform-server": "22.2.0-rc.0", + "@angular/router": "22.2.0-rc.0", + "@angular/service-worker": "22.2.0-rc.0", + "@babel/core": "8.0.6", "@bazel/bazelisk": "1.28.1", "@bazel/buildifier": "8.2.1", "@bazel/ibazel": "^0.28.0", @@ -69,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.63.3", + "@rollup/wasm-node": "4.63.5", "@stylistic/eslint-plugin": "^5.0.0", "@tony.ganchev/eslint-plugin-header": "~3.4.0", "@types/babel__core": "7.20.5", @@ -90,13 +91,13 @@ "@types/semver": "^7.3.12", "@types/yargs": "^17.0.20", "@types/yargs-parser": "^21.0.0", - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", "ajv": "8.20.0", "buffer": "6.0.3", "esbuild": "0.28.2", "esbuild-wasm": "0.28.2", - "eslint": "10.10.0", + "eslint": "10.11.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "express": "5.2.1", @@ -116,18 +117,18 @@ "karma-jasmine-html-reporter": "~2.3.0", "karma-source-map-support": "1.4.0", "lodash": "^4.17.21", - "magic-string": "1.4.1", + "magic-string": "1.4.2", "prettier": "^3.0.0", - "puppeteer": "25.11.0", + "puppeteer": "25.12.0", "quicktype-core": "26.0.0", - "rollup": "4.63.3", + "rollup": "4.63.5", "rollup-plugin-dts": "6.5.1", - "rollup-plugin-sourcemaps2": "0.5.8", + "rollup-plugin-sourcemaps2": "0.5.9", "semver": "7.8.5", "source-map-support": "0.5.21", "tslib": "2.8.1", - "undici": "8.10.2", - "verdaccio": "6.10.3", + "undici": "8.11.2", + "verdaccio": "6.10.4", "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 9a4de3b5aa64..e77d03277780 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -148,6 +148,7 @@ ts_project( ":node_modules/@babel/core", ":node_modules/oxc-parser", "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/router", "//:node_modules/@types/jasmine", "//:node_modules/esbuild", "//:node_modules/prettier", @@ -290,7 +291,6 @@ jasmine_test( name = "application_integration_tests", size = "medium", data = [":application_integration_test_lib"], - flaky = True, shard_count = 25, ) @@ -302,7 +302,6 @@ jasmine_test( # Force IPv4 to resolve RBE resolution issues "NODE_OPTIONS": "--dns-result-order=ipv4first", }, - flaky = True, shard_count = 10, ) @@ -315,7 +314,6 @@ jasmine_test( # or standardize to avoid complex configuration like this! "PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads", }, - flaky = True, shard_count = 10, ) @@ -323,7 +321,6 @@ jasmine_test( name = "unit-test_integration_tests", size = "medium", data = [":unit-test_integration_test_lib"], - flaky = True, shard_count = 5, ) diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 25d640c7619f..1e22cf0aae7b 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -20,7 +20,7 @@ "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", - "@babel/core": "8.0.5", + "@babel/core": "8.0.6", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", @@ -31,19 +31,19 @@ "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", - "magic-string": "1.4.1", + "magic-string": "1.4.2", "mrmime": "2.0.1", - "oxc-parser": "0.150.0", + "oxc-parser": "0.151.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", - "rolldown": "1.2.8", - "sass": "1.104.1", - "sass-embedded": "1.104.1", + "rolldown": "1.2.11", + "sass": "1.105.0", + "sass-embedded": "1.105.0", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", - "vite": "8.3.0", + "vite": "8.3.1", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { @@ -52,14 +52,14 @@ "devDependencies": { "@angular-devkit/core": "workspace:*", "@angular/ssr": "workspace:*", - "@oxc-project/types": "0.150.0", + "@oxc-project/types": "0.151.0", "@vitest/coverage-v8": "5.0.1", "istanbul-lib-instrument": "6.0.3", - "jsdom": "30.0.1", + "jsdom": "30.1.1", "less": "4.9.1", - "ng-packagr": "22.2.0-next.5", + "ng-packagr": "22.3.0-next.1", "postcss": "8.5.28", - "rollup": "4.63.3", + "rollup": "4.63.5", "rxjs": "7.8.2", "vitest": "5.0.1" }, diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index edd0ae7d22f0..b728ab9a8df5 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -7,8 +7,6 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; import { BuildOutputAsset, ExecutionResult, @@ -20,11 +18,9 @@ import { shutdownSassWorkerPool, } from '../../tools/esbuild/stylesheets/sass-language'; 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 { ChangedFiles } from '../../utils/watcher'; import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; import { ComponentUpdateResult, @@ -35,20 +31,6 @@ import { ResultMessage, } from './results'; -// Watch workspace for package manager changes -const packageWatchFiles = [ - // manifest can affect module resolution - 'package.json', - // npm lock file - 'package-lock.json', - // pnpm lock file - 'pnpm-lock.yaml', - // yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/) - 'yarn.lock', - '.pnp.cjs', - '.pnp.data.json', -]; - // eslint-disable-next-line max-lines-per-function export async function* runEsBuildBuildAction( action: (rebuildState?: RebuildState) => Promise, @@ -106,7 +88,7 @@ export async function* runEsBuildBuildAction( } } - let watcher: import('../../tools/esbuild/watcher').BuildWatcher | undefined; + let watcher: import('../../utils/watcher').BuildWatcher | undefined; let watchLoopStarted = false; try { // Setup watcher if watch mode enabled @@ -115,55 +97,18 @@ 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 - normalizedOutputBase, - `${normalizedOutputBase}/**`, - normalizedCacheBase, - `${normalizedCacheBase}/**`, - `${toPosixPath(workspaceRoot)}/**/.*/**`, - ]; - - if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) { - const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath); - ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`); - } - // Setup a watcher - const { createWatcher } = await import('../../tools/esbuild/watcher'); - watcher = await createWatcher({ - polling: typeof poll === 'number', - interval: poll, - followSymlinks: preserveSymlinks, - ignored, - cwd: workspaceRoot, + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath: outputOptions.base, + cacheOptions, + poll, + preserveSymlinks, + signal: options.signal, + watchFiles: result.watchFiles, }); - - // Setup abort support - options.signal?.addEventListener('abort', () => void watcher?.close()); - - // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set - if (shouldWatchRoot) { - if (!preserveSymlinks) { - // Ignore all node modules directories to avoid excessive file watchers. - // Package changes are handled below by watching manifest and lock files. - // NOTE: this is not enable when preserveSymlinks is true as this would break `npm link` usages. - ignored.push('**/node_modules/**'); - - watcher.add( - packageWatchFiles - .map((file) => path.join(workspaceRoot, file)) - .filter((file) => existsSync(file)), - ); - } - - watcher.add(projectRoot); - } - - // Watch locations provided by the initial build result - watcher.add(result.watchFiles); } // Output the first build results after setting up the watcher to ensure that any code executed @@ -213,7 +158,10 @@ export async function* runEsBuildBuildAction( // Clear removed files from current watch files changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); - resetSassWorkerPoolCaches(); + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } const rebuildState = result.createRebuildState(changes); result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 379566e4de4d..d3b628a169e2 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -20,7 +20,6 @@ import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; 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 { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; import { calculateEstimatedTransferSizes, @@ -29,6 +28,7 @@ import { } from '../../tools/esbuild/utils'; import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; import { optimizeChunksThreshold } from '../../utils/environment-options'; +import { profileAsync } from '../../utils/profiling'; import { resolveAssets } from '../../utils/resolve-assets'; import { SERVER_APP_ENGINE_MANIFEST_FILENAME, diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 07639a43c3e5..99e23bcb30c9 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -18,18 +18,15 @@ import { type InitialFileRecord, createOutputFile, } from '../../tools/esbuild/bundler-files'; -import { generateIndexHtml } from '../../tools/esbuild/index-html-generator'; import { maxWorkers } from '../../utils/environment-options'; import { SERVER_APP_MANIFEST_FILENAME, generateAngularServerAppManifest, } from '../../utils/server-rendering/manifest'; -import { - RouteRenderMode, - WritableSerializableRouteTreeNode, -} from '../../utils/server-rendering/models'; +import { WritableSerializableRouteTreeNode } from '../../utils/server-rendering/models'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { generateIndexHtml } from './index-html-generator'; import { injectDebugIds } from './inject-debug-ids'; import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; import { OutputMode } from './schema'; diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index d4656c046997..839384421f1f 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -16,8 +16,8 @@ import { PrerenderedRoutesRecord, } from '../../tools/esbuild/bundler-execution-result'; import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-files'; -import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; -import { maxWorkers } from '../../utils/environment-options'; +import { I18nInliner } from '../../tools/i18n'; +import { maxInlinerWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; import { createTranslationLoader } from '../../utils/load-translations'; import { createProjectResolver } from '../../utils/resolve-project'; @@ -51,7 +51,9 @@ export async function inlineI18n( const inliner = new I18nInliner( { missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', - maxConcurrency: workerPool ? undefined : maxWorkers, + maxConcurrency: workerPool + ? Math.min(workerPool.maxThreads, maxInlinerWorkers) + : maxInlinerWorkers, persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, localizeVersion: i18nOptions.localizeVersion, }, diff --git a/packages/angular/build/src/tools/esbuild/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts similarity index 96% rename from packages/angular/build/src/tools/esbuild/index-html-generator.ts rename to packages/angular/build/src/builders/application/index-html-generator.ts index a7ad3fd47e7f..d89eadf8b9b0 100644 --- a/packages/angular/build/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -9,9 +9,13 @@ import assert from 'node:assert'; import { createHash } from 'node:crypto'; import path from 'node:path'; -import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { + BuildOutputFile, + BuildOutputFileType, + InitialFileRecord, +} from '../../tools/esbuild/bundler-files'; import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; -import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-files'; +import { NormalizedApplicationBuildOptions } from './options'; /** * The maximum number of module preload link elements that should be added for diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index dc53d55b61f0..f7e60f4cc029 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -9,7 +9,6 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import { access, constants, readFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils'; import { supportColor } from '../../utils/color'; @@ -19,9 +18,8 @@ import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator' import { normalizeCacheOptions } from '../../utils/normalize-cache'; import { canonicalizePath } from '../../utils/path'; import { - SearchDirectory, - findTailwindConfiguration, generateSearchDirectories, + getTailwindConfig, loadPostcssConfiguration, } from '../../utils/postcss-configuration'; import { getProjectRootPaths, normalizeDirectoryPath } from '../../utils/project-metadata'; @@ -140,6 +138,11 @@ interface InternalOptions { * there. Used exclusively for tests and shouldn't be used for other kinds of builds. */ disableCodeSplitting?: boolean; + + /** + * An array of files to restrict the TypeScript compilation root names to. + */ + rootFiles?: string[]; } /** Full set of options for `application` builder. */ @@ -280,7 +283,7 @@ export async function normalizeOptions( // Skip tailwind configuration if postcss is customized const tailwindConfiguration = postcssConfiguration ? undefined - : await getTailwindConfig(searchDirectories, workspaceRoot, context); + : await getTailwindConfig(searchDirectories, workspaceRoot, context.logger); let serverEntryPoint: string | undefined; if (typeof options.server === 'string') { @@ -449,6 +452,7 @@ export async function normalizeOptions( verbose, watch, progress = true, + rootFiles, externalPackages, namedChunks, budgets, @@ -496,6 +500,7 @@ export async function normalizeOptions( workspaceRoot, entryPoints, disableCodeSplitting, + rootFiles: rootFiles?.map((file: string) => path.resolve(workspaceRoot, file)), optimizationOptions, outputOptions, outExtension, @@ -538,36 +543,6 @@ export async function normalizeOptions( }; } -async function getTailwindConfig( - searchDirectories: SearchDirectory[], - workspaceRoot: string, - context: BuilderContext, -): Promise<{ file: string; package: string } | undefined> { - const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); - - if (!tailwindConfigurationPath) { - return undefined; - } - - // Create a node resolver from the configuration file - const resolver = createRequire(tailwindConfigurationPath); - try { - return { - file: tailwindConfigurationPath, - package: resolver.resolve('tailwindcss'), - }; - } catch { - const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath); - context.logger.warn( - `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + - ` but the 'tailwindcss' package is not installed.` + - ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, - ); - } - - return undefined; -} - /** * Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser` * option which defines a single entry point. However, we also want to support multiple entry points as an internal option. diff --git a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts index afa42cc1804e..7ca19daefa53 100644 --- a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import fs from 'node:fs/promises'; import { buildApplication } from '../../index'; import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; @@ -408,6 +409,44 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/test.svg').toNotExist(); }); + + it('does not follow symlink directories by default', async () => { + await harness.writeFile('target/test.txt', 'symlink-content'); + await fs.symlink( + harness.resolvePath('target'), + harness.resolvePath('src/symlink-dir'), + 'junction', + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/symlink-dir/test.txt').toNotExist(); + }); + + it('follows symlink directories when followSymlinks is true', async () => { + await harness.writeFile('target/test.txt', 'symlink-content'); + await fs.symlink( + harness.resolvePath('target'), + harness.resolvePath('src/symlink-dir'), + 'junction', + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src', followSymlinks: true }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/symlink-dir/test.txt').content.toBe('symlink-content'); + }); }); }); }); 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 ca9212b0d3e3..d3319b099491 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 @@ -251,7 +251,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const indexHtml = harness.readFile('dist/browser/index.html'); expect(indexHtml).toMatch( - /const scripts = \[\[(?:'[^']+', 'module', false, false, "sha384-[^"]+", "anonymous"(?:, )?)+\]\];/, + /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 96587acb5cff..19ca0cf3e92e 100644 --- a/packages/angular/build/src/builders/dev-server/internal.ts +++ b/packages/angular/build/src/builders/dev-server/internal.ts @@ -8,7 +8,7 @@ export { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin'; -export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; +export { JavaScriptTransformer } from '../../tools/javascript-transformer'; export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils'; export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; export { purgeStaleBuildCache } from '../../utils/purge-cache'; diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts index 7d3e1ffc414b..82ecef04c209 100644 --- a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts @@ -235,5 +235,30 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT expect(result?.success).toBeTrue(); expect(response?.status).toBe(404); }); + + it('serves an HTML asset emitted via file loader', async () => { + const htmlFileContent = '

Custom HTML Asset

'; + await harness.writeFiles({ + 'src/test.html': htmlFileContent, + 'src/types.d.ts': 'declare module "*.html" { const url: string; export default url; }', + 'src/main.ts': 'import testHtml from "./test.html";\n console.log(testHtml);', + }); + + setupTarget(harness, { + loader: { + '.html': 'file', + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'media/test.html'); + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(200); + expect(response?.headers.get('content-type')).toContain('text/html'); + expect(await response?.text()).toBe(htmlFileContent); + }); }); }); diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-errors_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-errors_spec.ts index a17c0a198e14..9608e0dc976e 100644 --- a/packages/angular/build/src/builders/dev-server/tests/behavior/build-errors_spec.ts +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-errors_spec.ts @@ -38,8 +38,8 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT expectNoLog(logs, 'Unexpected character "EOF"'); }, ], - { outputLogsOnFailure: false, timeout: 60_000 }, + { outputLogsOnFailure: false }, ); - }, 90_000); + }); }); }); diff --git a/packages/angular/build/src/builders/karma/schema.json b/packages/angular/build/src/builders/karma/schema.json index 325f4298f779..50c739ff69c8 100644 --- a/packages/angular/build/src/builders/karma/schema.json +++ b/packages/angular/build/src/builders/karma/schema.json @@ -182,7 +182,7 @@ "type": "string" }, "default": ["**/*.spec.ts"], - "description": "Globs of files to include, relative to project root. \nThere are 2 special cases:\n - when a path to directory is provided, all spec files ending \".spec.@(ts|tsx)\" will be included\n - when a path to a file is provided, and a matching spec file exists it will be included instead." + "description": "Globs of files to include, relative to the project source root. \nThere are 2 special cases:\n - when a path to a directory is provided, all spec files ending \".spec.@(ts|tsx)\" will be included\n - when a path to a file is provided, and a matching spec file exists, it will be included instead." }, "exclude": { "type": "array", @@ -190,7 +190,7 @@ "type": "string" }, "default": [], - "description": "Globs of files to exclude, relative to the project root." + "description": "Globs of files to exclude, relative to the project source root." }, "sourceMap": { "description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.", diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index 755b91c40544..bb1da57a7108 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -295,7 +295,7 @@ export async function* execute( buildOptions: runnerBuildOptions, virtualFiles, testEntryPointMappings, - } = await runner.getBuildOptions(normalizedOptions, buildTargetOptions)); + } = await runner.getBuildOptions(normalizedOptions, buildTargetOptions, context.logger)); } catch (e) { assertIsError(e); context.logger.error( @@ -323,6 +323,7 @@ export async function* execute( const applicationBuildOptions = { ...buildTargetOptions, ...runnerBuildOptions, + polyfills: runnerBuildOptions.polyfills ?? normalizedOptions.polyfills, watch: normalizedOptions.watch, progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress, quiet: normalizedOptions.quiet, diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 44f6b6b9208b..5328a5861219 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -77,6 +77,7 @@ export async function normalizeOptions( runnerConfig, isolate, splitting = true, + polyfills, } = options; if (ui && runner !== Runner.Vitest) { @@ -158,6 +159,7 @@ export async function normalizeOptions( dumpVirtualFiles: options.dumpVirtualFiles, listTests: options.listTests, preserveSymlinks, + polyfills, runnerConfig: typeof runnerConfig === 'string' ? runnerConfig.length === 0 diff --git a/packages/angular/build/src/builders/unit-test/runners/api.ts b/packages/angular/build/src/builders/unit-test/runners/api.ts index 43f65ef68adc..4cd455472bfa 100644 --- a/packages/angular/build/src/builders/unit-test/runners/api.ts +++ b/packages/angular/build/src/builders/unit-test/runners/api.ts @@ -63,6 +63,7 @@ export interface TestRunner { getBuildOptions( options: NormalizedUnitTestBuilderOptions, baseBuildOptions: Partial, + logger: BuilderContext['logger'], ): RunnerOptions | Promise; /** diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts index 8b82857258b9..e558a3a1f3a7 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -76,7 +76,7 @@ export class KarmaExecutor implements TestExecutor { const karmaOptions: KarmaBuilderOptions = { karmaConfig, tsConfig: unitTestOptions.tsConfig ?? buildTargetOptions.tsConfig, - polyfills: injectTestingPolyfills(buildTargetOptions.polyfills), + polyfills: injectTestingPolyfills(unitTestOptions.polyfills ?? buildTargetOptions.polyfills), assets: buildTargetOptions.assets, scripts: buildTargetOptions.scripts, styles: buildTargetOptions.styles, 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 9c5abb9386bf..b3a7d8f37662 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 @@ -11,12 +11,13 @@ * Provides Vitest-specific build options and virtual file contents for Angular unit testing. */ +import type { BuilderContext } from '@angular-devkit/architect'; import path from 'node:path'; import { toPosixPath } from '../../../../utils/path'; import { createProjectResolver } from '../../../../utils/resolve-project'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; import { OutputHashing } from '../../../application/schema'; -import { NormalizedUnitTestBuilderOptions } from '../../options'; +import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; import { findTests, getTestEntrypoints } from '../../test-discovery'; import { RunnerOptions } from '../api'; @@ -26,14 +27,12 @@ import { RunnerOptions } from '../api'; * @param providersFile Optional path to a file that exports default providers. * @param projectSourceRoot The root directory of the project source. * @param teardown Whether to configure TestBed to destroy after each test. - * @param zoneTestingStrategy How zone.js should be loaded during initialization. * @returns The string content of the virtual initialization file. */ function createTestBedInitVirtualFile( providersFile: string | undefined, projectSourceRoot: string, teardown: boolean, - zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone', hasLocalize: boolean, ): string { let providersImport = 'const providers = [];'; @@ -44,21 +43,6 @@ function createTestBedInitVirtualFile( providersImport = `import providers from './${importPath}';`; } - let zoneTestingSnippet = ''; - if (zoneTestingStrategy === 'static') { - zoneTestingSnippet = `import 'zone.js/testing';`; - } else if (zoneTestingStrategy === 'dynamic') { - zoneTestingSnippet = `if (typeof Zone !== 'undefined') { - // 'zone.js/testing' is used to initialize the ZoneJS testing environment. - // It must be imported dynamically to avoid a static dependency on 'zone.js'. - await import('zone.js/testing'); - }`; - } else if (zoneTestingStrategy === 'dynamic-zone') { - zoneTestingSnippet = ` - await import('zone.js'); - await import('zone.js/testing');`; - } - // The DynamicDOMTestComponentRenderer is used to avoid stale document references // when running Vitest in non-isolated mode with JSDOM. It looks up the // document dynamically on every operation instead of caching it. @@ -72,8 +56,6 @@ function createTestBedInitVirtualFile( import { afterEach, beforeEach } from 'vitest'; ${providersImport} - ${zoneTestingSnippet} - // The beforeEach and afterEach hooks are registered outside the globalThis guard. // This ensures that the hooks are always applied, even in non-isolated browser environments. // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29 @@ -150,37 +132,37 @@ function adjustOutputHashing(hashing?: OutputHashing): OutputHashing { } /** - * Resolves the Zone.js testing strategy by inspecting polyfills and resolving zone.js package. + * Injects Zone.js and Zone.js testing polyfills into the build options based on the + * project configuration and `polyfills` option. * - * @param buildOptions The partial application builder options. + * @param polyfills The configured polyfills from the test or build target. * @param projectSourceRoot The root directory of the project source. - * @returns The resolved zone testing strategy ('none', 'static', 'dynamic', 'dynamic-zone'). + * @param logger The logger instance for reporting deprecation warnings. + * @returns An array of polyfill specifiers to use for testing. */ -function getZoneTestingStrategy( - buildOptions: Partial, +function injectZoneJsTestingPolyfills( + polyfills: string[] | undefined, projectSourceRoot: string, -): 'none' | 'static' | 'dynamic' | 'dynamic-zone' { - if (buildOptions.polyfills?.includes('zone.js/testing')) { - return 'none'; - } - - if (buildOptions.polyfills?.includes('zone.js')) { - return 'static'; + logger: BuilderContext['logger'], +): string[] { + if (polyfills) { + return injectTestingPolyfills(polyfills); } + // If polyfills is undefined (e.g. library build target), attempt to load zone.js if installed. try { const projectResolve = createProjectResolver(projectSourceRoot); projectResolve('zone.js'); - // If polyfills is undefined (e.g. library build target), load zone.js dynamically. - // If polyfills is defined but doesn't include zone.js (e.g. zoneless application), do NOT load zone.js. - if (buildOptions.polyfills === undefined) { - return 'dynamic-zone'; - } + logger.warn( + 'Zone.js polyfills are being automatically injected because "zone.js" was detected in the project dependencies. ' + + 'This behavior is deprecated. If your project is zoneless, set the "polyfills" option to an empty array ("[]") in the ' + + 'test configuration. Otherwise, explicitly add "zone.js" to the "polyfills" option.', + ); - return 'dynamic'; + return ['zone.js', 'zone.js/testing']; } catch { - return 'none'; + return []; } } @@ -192,13 +174,24 @@ function getZoneTestingStrategy( * * @param options The normalized unit test builder options. * @param baseBuildOptions The base build config to derive testing config from. + * @param logger The logger instance for reporting deprecation warnings. * @returns An async RunnerOptions configuration. */ export async function getVitestBuildOptions( options: NormalizedUnitTestBuilderOptions, baseBuildOptions: Partial, + logger: BuilderContext['logger'], ): Promise { - const { workspaceRoot, projectSourceRoot, include, exclude = [], watch, providersFile } = options; + const { + workspaceRoot, + projectSourceRoot, + include, + polyfills, + exclude = [], + watch, + providersFile, + setupFiles, + } = options; // Find test files const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); @@ -217,8 +210,15 @@ export async function getVitestBuildOptions( removeTestExtension: true, }); - if (options.setupFiles?.length) { - const setupEntryPoints = getTestEntrypoints(options.setupFiles, { + const rootFiles = [...testFiles]; + if (providersFile) { + rootFiles.push(providersFile); + } + + if (setupFiles?.length) { + rootFiles.push(...setupFiles); + + const setupEntryPoints = getTestEntrypoints(setupFiles, { projectSourceRoot, workspaceRoot, removeTestExtension: false, @@ -241,6 +241,11 @@ export async function getVitestBuildOptions( const buildOptions: Partial = { ...baseBuildOptions, + polyfills: injectZoneJsTestingPolyfills( + polyfills ?? baseBuildOptions.polyfills, + projectSourceRoot, + logger, + ), watch, incrementalResults: watch, index: false, @@ -258,6 +263,7 @@ export async function getVitestBuildOptions( optimization: false, namedChunks: false, entryPoints, + rootFiles, // Vitest's Node-based module loading emulation (vite-node) is not fully spec compliant and lacks // live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking. // Disabling code splitting avoids shared chunks, but increases build and coverage memory/time. @@ -268,9 +274,6 @@ export async function getVitestBuildOptions( externalDependencies, }; - // Inject the zone.js testing polyfill if Zone.js is installed. - const zoneTestingStrategy = getZoneTestingStrategy(buildOptions, projectSourceRoot); - let hasLocalize = false; try { const projectResolve = createProjectResolver(projectSourceRoot); @@ -282,7 +285,6 @@ export async function getVitestBuildOptions( providersFile, projectSourceRoot, !options.debug, - zoneTestingStrategy, hasLocalize, ); diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index fc64e432e19f..55003b4fc82a 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -333,6 +333,7 @@ export class VitestExecutor implements TestExecutor { projectName, buildResultFiles: this.buildResultFiles, testFileToEntryPoint: this.testFileToEntryPoint, + setupFiles: testSetupFiles, }); const debugOptions = debug diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts index a1342e5abba8..f879628a2462 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -9,7 +9,6 @@ import assert from 'node:assert'; import type { TestRunner } from '../api'; import { DependencyChecker } from '../dependency-checker'; -import { normalizeBrowserName } from './browser-provider'; import { getVitestBuildOptions } from './build-options'; import { VitestExecutor } from './executor'; @@ -60,8 +59,8 @@ const VitestTestRunner: TestRunner = { checker.report(); }, - getBuildOptions(options, baseBuildOptions) { - return getVitestBuildOptions(options, baseBuildOptions); + getBuildOptions(options, baseBuildOptions, logger) { + return getVitestBuildOptions(options, baseBuildOptions, logger); }, async createExecutor(context, options, testEntryPointMappings) { diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index ee81e417bb5d..6be4c6e8ebe3 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -37,6 +37,7 @@ interface PluginOptions { projectName: string; buildResultFiles: ReadonlyMap; testFileToEntryPoint: ReadonlyMap; + setupFiles: readonly string[]; } type VitestCoverageOption = Exclude; @@ -313,8 +314,13 @@ async function loadResultFile(file: ResultFile): Promise { } export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { - const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; + const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions; const isWindows = platform() === 'win32'; + const setupFileSet = new Set( + setupFiles.map((file) => + toPosixPath(path.isAbsolute(file) ? file : path.join(workspaceRoot, file)), + ), + ); let vitestConfig: ResolvedConfig; return [ @@ -387,7 +393,11 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] if (entryPoint) { outputPath = entryPoint + '.js'; - if (vitestConfig?.coverage?.enabled) { + // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates + // the setup file itself between test files; wrapping it would cause the underlying bundle + // to be cached, preventing per-test hooks from running on subsequent test files. + const isSetupFile = setupFileSet.has(id); + if (vitestConfig?.coverage?.enabled && !isSetupFile) { // To support coverage exclusion of the actual test file, the virtual // test entry point only references the built and bundled intermediate file. // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely. diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 58a9592986b1..66081d555cb1 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -43,14 +43,14 @@ "type": "string" }, "default": ["**/*.spec.ts", "**/*.test.ts"], - "description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)." + "description": "Specifies glob patterns of files to include for testing, relative to the project source root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)." }, "exclude": { "type": "array", "items": { "type": "string" }, - "description": "Specifies glob patterns of files to exclude from testing, relative to the project root." + "description": "Specifies glob patterns of files to exclude from testing, relative to the project source root." }, "filter": { "type": "string", @@ -265,6 +265,14 @@ "description": "Specifies the path to a TypeScript file that provides an array of Angular providers for the test environment. The file must contain a default export of the provider array.", "minLength": 1 }, + "polyfills": { + "description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'. If not specified, polyfills will be inherited from the build target.", + "type": "array", + "items": { + "type": "string", + "uniqueItems": true + } + }, "setupFiles": { "type": "array", "items": { diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts index 3caf15a2cf3f..147cfc31c53e 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -4,6 +4,8 @@ import { describeBuilder, UNIT_TEST_BUILDER_INFO, setupApplicationTarget, + expectLog, + expectNoLog, } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { @@ -68,7 +70,61 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); }); - it('should load Zone and Zone testing support when testing a library and zone.js is installed', async () => { + it('should NOT load Zone when test polyfills is empty even if zone.js is in build polyfills', async () => { + setupApplicationTarget(harness, { + polyfills: ['zone.js'], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zoneless Override Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should load Zone when test polyfills includes zone.js even if build polyfills is empty', async () => { + setupApplicationTarget(harness, { + polyfills: [], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zone Forced Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should load Zone and emit a deprecation warning when testing a library and zone.js is installed', async () => { harness.withBuilderTarget( 'build', async () => ({ success: true }), @@ -107,8 +163,54 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { `, ); - const { result } = await harness.executeOnce(); + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /Zone\.js polyfills are being automatically injected/); + }); + + it('should NOT load Zone and not emit warning when testing a library with polyfills: []', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + project: 'ng-package.json', + }, + { + builderName: '@angular/build:ng-packagr', + }, + ); + + await harness.writeFile( + 'ng-package.json', + JSON.stringify({ + lib: { + entryFile: 'src/public-api.ts', + }, + }), + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + include: ['src/app.component.spec.ts'], + }); + + await harness.writeFile( + 'src/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Library Zoneless Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); expect(result?.success).toBeTrue(); + expectNoLog(logs, /Zone\.js polyfills are being automatically injected/); }); }); }); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts index f1cdd4a14f0b..d3d4e19a30f9 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts @@ -83,5 +83,32 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(result?.success).toBeTrue(); }); }); + + it('should ignore TypeScript compilation errors in non-included test files', async () => { + await harness.writeFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/broken.service.spec.ts': ` + // This test has a TypeScript type error that would fail compilation if compiled + const invalidNumber: number = 'not a number'; + describe('BrokenService', () => { + it('should fail compilation', () => { + expect(invalidNumber).toBe(1); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/services/test.service.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); }); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..06bc3bd8fc98 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts @@ -0,0 +1,96 @@ +/** + * @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('Option: "polyfills"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should include polyfills specified on test target', async () => { + await harness.writeFiles({ + 'src/custom-polyfill.js': `globalThis['CUSTOM_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Test', () => { + test('should have run test polyfill', () => { + expect((globalThis as any)['CUSTOM_POLYFILL_RAN']).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/custom-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should override buildTarget polyfills when polyfills is specified on test target', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/test-polyfill.js': `globalThis['TEST_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Override Test', () => { + test('should have run test polyfill and not app polyfill', () => { + expect((globalThis as any)['TEST_POLYFILL_RAN']).toBe(true); + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/test-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow overriding buildTarget polyfills with an empty array', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Empty Polyfill Override Test', () => { + test('should not have run app polyfill', () => { + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 65d0dadf17b8..44241bbcfd8a 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -54,5 +54,68 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + it('should run setup file hooks for each spec file when coverage is enabled', async () => { + await harness.writeFiles({ + 'custom-vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + }, + }); + `, + 'src/setup.ts': ` + import { afterEach, beforeEach, expect } from 'vitest'; + const global = globalThis as typeof globalThis & { + setupHookCalls?: string[]; + }; + const setupHookCalls = (global.setupHookCalls ??= []); + beforeEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('beforeEach:' + testName); + }); + afterEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('afterEach:' + testName); + }); + `, + 'src/app/app.component.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec'); + }); + it('runs setup hooks for second test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec'); + }); + `, + 'src/app/second.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec'); + }); + it('runs setup hooks for second test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'custom-vitest.config.mts', + setupFiles: ['src/setup.ts'], + }); + + 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 1fe8765b2646..9db709640b57 100644 --- a/packages/angular/build/src/private.ts +++ b/packages/angular/build/src/private.ts @@ -39,9 +39,9 @@ export { transformSupportedBrowsersToTargets } from './tools/esbuild/target'; export { SassCompiler } from './tools/sass/sass-service'; export { SourceFileCache } from './tools/esbuild/angular/source-file-cache'; -export { Cache } from './tools/esbuild/cache'; +export { Cache } from './utils/cache'; export { createJitResourceTransformer } from './tools/angular/transformers/jit-resource-transformer'; -export { JavaScriptTransformer } from './tools/esbuild/javascript-transformer'; +export { JavaScriptTransformer } from './tools/javascript-transformer'; export function createCompilerPlugin( pluginOptions: CompilerPluginOptions & { 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 6bd836139c5f..d766680d1674 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -23,6 +23,7 @@ export interface FileTransformResult { export interface AngularCompilationOptions { allowJs?: boolean; + declarationMap?: boolean; isolatedModules?: boolean; sourceMap?: boolean; inlineSourceMap?: boolean; @@ -52,6 +53,7 @@ export abstract class AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', ): Promise; emitAffectedFiles(): Iterable | Promise> { 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 ca74e721ba7d..e4047a3e3e1b 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -11,7 +11,7 @@ import assert from 'node:assert'; import { relative } from 'node:path'; import ts from 'typescript'; import { useTypeChecking } from '../../../utils/environment-options'; -import { profileAsync, profileSync } from '../../esbuild/profiling'; +import { profileAsync, profileSync } from '../../../utils/profiling'; import { AngularHostOptions, createAngularCompilerHost, @@ -61,10 +61,12 @@ export class AotCompilation extends TypeScriptCompilation { super(); } + // eslint-disable-next-line max-lines-per-function async initialize( tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { NgtscProgram, OptimizeFor } = await TypeScriptCompilation.loadCompilerCli(); @@ -75,7 +77,7 @@ export class AotCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); const useTypeScriptTranspilation = (compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ?? @@ -332,9 +334,11 @@ export class AotCompilation extends TypeScriptCompilation { useTypeScriptTranspilation, } = this.#state; const compilerOptions = typeScriptProgram.getCompilerOptions(); + const isLibraryEmit = !!compilerOptions.declaration; const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; - const emittedFiles = new Map(); + const emittedFiles = new Map(); + const emittedSourceFiles = new Set(); const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { // Save builder info contents to specified location @@ -350,17 +354,21 @@ export class AotCompilation extends TypeScriptCompilation { } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + const targetFilename = isLibraryEmit ? filename : sourceFile.fileName; + emittedFiles.set(targetFilename, { filename: targetFilename, contents }); }; const transformers = angularCompiler.prepareEmit().transformers; - transformers.before ??= []; - transformers.before.push( - replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), - webWorkerTransform, - ); + if (!isLibraryEmit) { + transformers.before ??= []; + transformers.before.push( + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ); - if (!this.browserOnlyBuild) { - transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } } // Emit is handled in write file callback when using TypeScript @@ -394,7 +402,7 @@ export class AotCompilation extends TypeScriptCompilation { // Angular may have files that must be emitted but TypeScript does not consider affected for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (emittedFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + if (emittedSourceFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { continue; } @@ -410,7 +418,8 @@ export class AotCompilation extends TypeScriptCompilation { } if (useTypeScriptTranspilation) { - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + const emitOnly = affectedFiles.has(sourceFile) ? undefined : false; + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, emitOnly, transformers); continue; } @@ -451,13 +460,14 @@ export class AotCompilation extends TypeScriptCompilation { contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; } else if (compilerOptions.sourceMap) { const mapFilename = sourceFile.fileName + '.map'; - emittedFiles.set(sourceFile, { filename: mapFilename, contents: printResult.map }); + emittedFiles.set(mapFilename, { filename: mapFilename, contents: printResult.map }); } } } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + emittedFiles.set(sourceFile.fileName, { filename: sourceFile.fileName, contents }); } return emittedFiles.values(); diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts index bb8c494f2213..1720a8bb0516 100644 --- a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -20,6 +20,10 @@ export interface CompilerOptionOverrides { instrumentForCoverage?: boolean; includeTestMetadata?: boolean; customConditions?: string[]; + rootFiles?: string[]; + declarationMap?: boolean; + compilationMode?: 'full' | 'partial'; + paths?: Record; } export function transformCompilerOptions( @@ -27,9 +31,11 @@ export function transformCompilerOptions( baseCompilerOptions: ng.CompilerOptions, overrides?: CompilerOptionOverrides, tsconfig?: string, + buildType: 'application' | 'library' = 'application', ): { compilerOptions: ng.CompilerOptions; warnings: PartialMessage[] } { const compilerOptions = { ...baseCompilerOptions }; const warnings: PartialMessage[] = []; + const isLibrary = buildType === 'library'; if ( compilerOptions.target === undefined || @@ -56,13 +62,15 @@ export function transformCompilerOptions( }); } - if (compilerOptions.compilationMode === 'partial') { + if (!isLibrary && compilerOptions.compilationMode === 'partial') { warnings.push({ text: 'Angular partial compilation mode is not supported when building applications.', location: null, notes: [{ text: 'Full compilation mode will be used instead.' }], }); compilerOptions.compilationMode = 'full'; + } else if (overrides?.compilationMode) { + compilerOptions.compilationMode = overrides.compilationMode; } // Enable incremental compilation by default if caching is enabled and incremental is not explicitly disabled @@ -100,6 +108,16 @@ export function transformCompilerOptions( }); } + if (isLibrary) { + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.module = typeScript.ModuleKind.ES2022; + compilerOptions.moduleResolution = typeScript.ModuleResolutionKind.Bundler; + compilerOptions.importHelpers = true; + compilerOptions.declaration = true; + compilerOptions.declarationMap = overrides?.declarationMap; + compilerOptions.declarationDir = undefined; + } + // Synchronize custom resolve conditions. // Set if using the supported bundler resolution mode (bundler is the default in new projects) if ( @@ -115,21 +133,25 @@ export function transformCompilerOptions( noEmitOnError: false, composite: false, inlineSources: !!overrides?.sourcemap, - inlineSourceMap: !!overrides?.sourcemap, - sourceMap: undefined, + inlineSourceMap: !isLibrary && !!overrides?.sourcemap, + sourceMap: isLibrary ? !!overrides?.sourcemap : undefined, mapRoot: undefined, sourceRoot: undefined, preserveSymlinks: overrides?.preserveSymlinks, externalRuntimeStyles: overrides?.externalRuntimeStyles, _enableHmr: !!overrides?.enableHmr, // TypeScript transpilation is forced if: + // - Building a library (TypeScript emits both .js and .d.ts in a single pass). // - isolatedModules is disabled (TS needs full module types to emit JS). // - Karma code coverage is active (the coverage instrumentation transformer is Babel-based // and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream). _useTypeScriptTranspilation: - !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, - supportTestBed: !!overrides?.includeTestMetadata, - supportJitMode: !!overrides?.includeTestMetadata, + isLibrary || !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, + supportTestBed: isLibrary ? undefined : !!overrides?.includeTestMetadata, + supportJitMode: isLibrary ? undefined : !!overrides?.includeTestMetadata, + paths: overrides?.paths + ? { ...baseCompilerOptions.paths, ...overrides.paths } + : baseCompilerOptions.paths, }, warnings, }; 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 233f5977e5a6..773279e236a9 100644 --- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -9,7 +9,7 @@ import type * as ng from '@angular/compiler-cli'; import assert from 'node:assert'; import ts from 'typescript'; -import { profileSync } from '../../esbuild/profiling'; +import { profileSync } from '../../../utils/profiling'; import { AngularHostOptions, createAngularCompilerHost } from '../angular-host'; import { createJitResourceTransformer } from '../transformers/jit-resource-transformer'; import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer'; @@ -43,6 +43,7 @@ export class JitCompilation extends TypeScriptCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { constructorParametersDownlevelTransform } = @@ -54,7 +55,7 @@ export class JitCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); if (hostOptions.modifiedFiles) { this.invalidateFiles(hostOptions.modifiedFiles); 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 4df13b3a500e..6e4debf62501 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -9,8 +9,8 @@ import type { PartialMessage } from 'esbuild'; import { createRequire } from 'node:module'; import { MessageChannel } from 'node:worker_threads'; +import { mergeCumulativeDurations } from '../../../utils/profiling'; import { WorkerPool } from '../../../utils/worker-pool'; -import { mergeCumulativeDurations } from '../../esbuild/profiling'; import type { AngularHostOptions } from '../angular-host'; import { AngularCompilation, @@ -52,6 +52,7 @@ export class ParallelCompilation extends AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { const stylesheetChannel = new MessageChannel(); // The request identifier is required because Angular can issue multiple concurrent requests @@ -94,6 +95,7 @@ export class ParallelCompilation extends AngularCompilation { jit: this.jit, browserOnlyBuild: this.browserOnlyBuild, compilerOptionOverrides, + buildType, stylesheetPort: stylesheetChannel.port2, webWorkerPort: webWorkerChannel.port2, webWorkerSignal, 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 71b4d34e2756..a74c032a1b70 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -11,7 +11,7 @@ import assert from 'node:assert'; import { randomUUID } from 'node:crypto'; import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads'; import { initializeHash } from '../../../utils/hash'; -import { getAndClearCumulativeDurations } from '../../esbuild/profiling'; +import { getAndClearCumulativeDurations } from '../../../utils/profiling'; import type { AngularCompilation, AngularCompilationResult, @@ -27,6 +27,7 @@ export interface InitRequest { tsconfig: string; fileReplacements?: Record; compilerOptionOverrides?: CompilerOptionOverrides; + buildType?: 'application' | 'library'; stylesheetPort: MessagePort; webWorkerPort: MessagePort; webWorkerSignal: Int32Array; @@ -111,6 +112,7 @@ export async function initialize(request: InitRequest): Promise { const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); const { options: originalCompilerOptions, - rootNames, + rootNames: originalRootNames, errors, } = profileSync('NG_READ_CONFIG', () => readConfiguration(tsconfig, { @@ -60,11 +61,25 @@ export abstract class TypeScriptCompilation extends AngularCompilation { }), ); + let rootNames = originalRootNames; + if (compilerOptionOverrides?.rootFiles?.length) { + const rootFilesSet = new Set( + compilerOptionOverrides.rootFiles.map((file) => canonicalizePath(toPosixPath(file))), + ); + for (const file of originalRootNames) { + if (/\.d\.[cm]?ts$/i.test(file)) { + rootFilesSet.add(canonicalizePath(toPosixPath(file))); + } + } + rootNames = [...rootFilesSet]; + } + const { compilerOptions, warnings } = transformCompilerOptions( ts, originalCompilerOptions, compilerOptionOverrides, tsconfig, + buildType, ); return { 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 index 5a11959fff66..245443781601 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts @@ -26,11 +26,20 @@ function isNode(node: unknown): node is Node { return typeof node === 'object' && node !== null && 'type' in node; } +function unwrapParentheses(node: unknown): unknown { + while (isNode(node) && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + /** * An implementation of `AstHost` that queries information from `oxc-parser` AST nodes. */ export class OxcAstHost implements AstHost { getSymbolName(node: unknown): string | null { + node = unwrapParentheses(node); if (!isNode(node)) { return null; } @@ -47,10 +56,13 @@ export class OxcAstHost implements AstHost { } isStringLiteral(node: unknown): node is StringLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && typeof node.value === 'string'; } parseStringLiteral(str: unknown): string { + str = unwrapParentheses(str); if (!this.isStringLiteral(str)) { throw new FatalLinkerError(str as object, 'Unsupported syntax, expected a string literal.'); } @@ -59,10 +71,13 @@ export class OxcAstHost implements AstHost { } isNumericLiteral(node: unknown): node is NumericLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && typeof node.value === 'number'; } parseNumericLiteral(num: unknown): number { + num = unwrapParentheses(num); if (!this.isNumericLiteral(num)) { throw new FatalLinkerError(num as object, 'Unsupported syntax, expected a numeric literal.'); } @@ -71,6 +86,7 @@ export class OxcAstHost implements AstHost { } isBooleanLiteral(node: unknown): node is BooleanLiteral | UnaryExpression { + node = unwrapParentheses(node); if (!isNode(node)) { return false; } @@ -81,6 +97,7 @@ export class OxcAstHost implements AstHost { } parseBooleanLiteral(bool: unknown): boolean { + bool = unwrapParentheses(bool); if (isNode(bool)) { if (bool.type === 'Literal' && typeof bool.value === 'boolean') { return bool.value; @@ -94,14 +111,19 @@ export class OxcAstHost implements AstHost { } isNull(node: unknown): node is NullLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && node.value === null; } isArrayLiteral(node: unknown): node is ArrayExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'ArrayExpression'; } parseArrayLiteral(array: unknown): unknown[] { + array = unwrapParentheses(array); if (!this.isArrayLiteral(array)) { throw new FatalLinkerError(array as object, 'Unsupported syntax, expected an array literal.'); } @@ -115,23 +137,27 @@ export class OxcAstHost implements AstHost { 'Unsupported syntax, element in array not to be empty.', ); } - if (element.type === 'SpreadElement') { + const unwrappedElement = unwrapParentheses(element); + if (isNode(unwrappedElement) && unwrappedElement.type === 'SpreadElement') { throw new FatalLinkerError( - element as object, + unwrappedElement as object, 'Unsupported syntax, element in array not to use spread syntax.', ); } - result.push(element); + result.push(unwrappedElement); } return result; } isObjectLiteral(node: unknown): node is ObjectExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'ObjectExpression'; } parseObjectLiteral(obj: unknown): Map { + obj = unwrapParentheses(obj); if (!this.isObjectLiteral(obj)) { throw new FatalLinkerError(obj as object, 'Unsupported syntax, expected an object literal.'); } @@ -146,7 +172,13 @@ export class OxcAstHost implements AstHost { ); } - const keyNode = property.key; + const keyNode = unwrapParentheses(property.key); + if (!isNode(keyNode)) { + throw new FatalLinkerError( + property.key as object, + 'Unsupported syntax, expected a property name.', + ); + } let key: string; if (keyNode.type === 'Identifier') { @@ -162,13 +194,14 @@ export class OxcAstHost implements AstHost { ); } - result.set(key, property.value); + result.set(key, unwrapParentheses(property.value)); } return result; } isFunctionExpression(node: unknown): node is FunctionNode | ArrowFunctionExpression { + node = unwrapParentheses(node); if (!isNode(node)) { return false; } @@ -181,6 +214,7 @@ export class OxcAstHost implements AstHost { } parseReturnValue(fn: unknown): unknown { + fn = unwrapParentheses(fn); if (!this.isFunctionExpression(fn)) { throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); } @@ -191,7 +225,7 @@ export class OxcAstHost implements AstHost { } if (body.type !== 'BlockStatement') { - return body; + return unwrapParentheses(body); } const statements = body.body; @@ -217,10 +251,11 @@ export class OxcAstHost implements AstHost { ); } - return stmt.argument; + return unwrapParentheses(stmt.argument); } parseParameters(fn: unknown): unknown[] { + fn = unwrapParentheses(fn); if (!this.isFunctionExpression(fn)) { throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); } @@ -229,18 +264,22 @@ export class OxcAstHost implements AstHost { } isCallExpression(node: unknown): node is CallExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'CallExpression'; } parseCallee(call: unknown): unknown { + call = unwrapParentheses(call); if (!this.isCallExpression(call)) { throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); } - return call.callee; + return unwrapParentheses(call.callee); } parseArguments(call: unknown): unknown[] { + call = unwrapParentheses(call); if (!this.isCallExpression(call)) { throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); } @@ -248,19 +287,21 @@ export class OxcAstHost implements AstHost { const result: unknown[] = []; for (const arg of call.arguments) { - if (arg.type === 'SpreadElement') { + const unwrappedArg = unwrapParentheses(arg); + if (isNode(unwrappedArg) && unwrappedArg.type === 'SpreadElement') { throw new FatalLinkerError( - arg as object, + unwrappedArg as object, 'Unsupported syntax, argument not to use spread syntax.', ); } - result.push(arg); + result.push(unwrappedArg); } return result; } getRange(node: unknown): Range { + node = unwrapParentheses(node); if (!isNode(node) || typeof node.start !== 'number' || typeof node.end !== 'number') { throw new FatalLinkerError( node as object, 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 index 79027584066d..cbe0d6a25292 100644 --- 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 @@ -34,11 +34,13 @@ describe('OxcAstHost', () => { it('should return the name of an identifier', () => { const expr = parseExpression('foo'); expect(host.getSymbolName(expr)).toBe('foo'); + expect(host.getSymbolName(parseExpression('(foo)'))).toBe('foo'); }); it('should return the property name of a member expression', () => { const expr = parseExpression('foo.bar'); expect(host.getSymbolName(expr)).toBe('bar'); + expect(host.getSymbolName(parseExpression('(foo.bar)'))).toBe('bar'); }); it('should return null for non-identifier or computed member expressions', () => { @@ -53,6 +55,10 @@ describe('OxcAstHost', () => { const expr = parseExpression('"hello"'); expect(host.isStringLiteral(expr)).toBe(true); expect(host.parseStringLiteral(expr)).toBe('hello'); + + const parenthesized = parseExpression('("hello")'); + expect(host.isStringLiteral(parenthesized)).toBe(true); + expect(host.parseStringLiteral(parenthesized)).toBe('hello'); }); it('should throw when parsing non-string literals', () => { @@ -67,6 +73,10 @@ describe('OxcAstHost', () => { const expr = parseExpression('123'); expect(host.isNumericLiteral(expr)).toBe(true); expect(host.parseNumericLiteral(expr)).toBe(123); + + const parenthesized = parseExpression('(123)'); + expect(host.isNumericLiteral(parenthesized)).toBe(true); + expect(host.parseNumericLiteral(parenthesized)).toBe(123); }); it('should throw when parsing non-numeric literals', () => { @@ -84,6 +94,10 @@ describe('OxcAstHost', () => { expect(host.parseBooleanLiteral(trueExpr)).toBe(true); expect(host.isBooleanLiteral(falseExpr)).toBe(true); expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedTrue = parseExpression('(true)'); + expect(host.isBooleanLiteral(parenthesizedTrue)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedTrue)).toBe(true); }); it('should recognize and parse minified boolean literals (!0 and !1)', () => { @@ -93,6 +107,10 @@ describe('OxcAstHost', () => { expect(host.parseBooleanLiteral(trueExpr)).toBe(true); expect(host.isBooleanLiteral(falseExpr)).toBe(true); expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedMinified = parseExpression('(!0)'); + expect(host.isBooleanLiteral(parenthesizedMinified)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedMinified)).toBe(true); }); it('should return false for invalid boolean expressions', () => { @@ -106,6 +124,18 @@ describe('OxcAstHost', () => { const expr = parseExpression('[1, "a", true]'); expect(host.isArrayLiteral(expr)).toBe(true); expect(host.parseArrayLiteral(expr).length).toBe(3); + + const parenthesized = parseExpression('([1, "a", true])'); + expect(host.isArrayLiteral(parenthesized)).toBe(true); + expect(host.parseArrayLiteral(parenthesized).length).toBe(3); + }); + + it('should unwrap parenthesized elements in array literals', () => { + const expr = parseExpression('[(1), ("a")]'); + const elements = host.parseArrayLiteral(expr); + expect(elements.length).toBe(2); + expect(host.isNumericLiteral(elements[0])).toBe(true); + expect(host.isStringLiteral(elements[1])).toBe(true); }); it('should throw when array contains empty elements or spread syntax', () => { @@ -115,6 +145,9 @@ describe('OxcAstHost', () => { expect(() => host.parseArrayLiteral(parseExpression('[1, ...a]'))).toThrowError( FatalLinkerError, ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...(a)]'))).toThrowError( + FatalLinkerError, + ); }); }); @@ -130,6 +163,16 @@ describe('OxcAstHost', () => { expect(map.has('3')).toBe(true); }); + it('should recognize and parse parenthesized object literals', () => { + const expr = parseExpression('({ a: (1), b: ("c") })'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(2); + expect(host.isNumericLiteral(map.get('a'))).toBe(true); + expect(host.isStringLiteral(map.get('b'))).toBe(true); + }); + it('should throw when object literal contains spread or non-property assignments', () => { expect(() => host.parseObjectLiteral(parseExpression('{ ...a }'))).toThrowError( FatalLinkerError, @@ -147,6 +190,21 @@ describe('OxcAstHost', () => { expect(host.isNumericLiteral(returnValue)).toBe(true); }); + it('should parse parenthesized arrow functions and concise bodies returning parenthesized object literals', () => { + const expr = parseExpression('() => ({ a: 1 })'); + expect(host.isFunctionExpression(expr)).toBe(true); + + const returnValue = host.parseReturnValue(expr); + expect(host.isObjectLiteral(returnValue)).toBe(true); + const map = host.parseObjectLiteral(returnValue); + expect(map.has('a')).toBe(true); + + const wrappedArrow = parseExpression('((a) => (42))'); + expect(host.isFunctionExpression(wrappedArrow)).toBe(true); + expect(host.parseParameters(wrappedArrow).length).toBe(1); + expect(host.isNumericLiteral(host.parseReturnValue(wrappedArrow))).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); @@ -155,6 +213,14 @@ describe('OxcAstHost', () => { expect(host.isStringLiteral(returnValue)).toBe(true); }); + it('should parse return value from function returning parenthesized expression', () => { + const stmt = parseStatement('function foo() { return ({ a: 1 }); }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isObjectLiteral(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; }')), @@ -171,6 +237,14 @@ describe('OxcAstHost', () => { expect(host.isCallExpression(expr)).toBe(true); expect(host.getSymbolName(host.parseCallee(expr))).toBe('foo'); expect(host.parseArguments(expr).length).toBe(2); + + const parenthesized = parseExpression('((foo)((1), ("a")))'); + expect(host.isCallExpression(parenthesized)).toBe(true); + expect(host.getSymbolName(host.parseCallee(parenthesized))).toBe('foo'); + const args = host.parseArguments(parenthesized); + expect(args.length).toBe(2); + expect(host.isNumericLiteral(args[0])).toBe(true); + expect(host.isStringLiteral(args[1])).toBe(true); }); it('should throw when call expression arguments contain spread syntax', () => { diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts index 26133a6b5611..8870fe1cc7da 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { ConsoleLogger, LogLevel } from '@angular/compiler-cli'; +import type { Logger } from '@angular/compiler-cli'; import { type DeclarationScope, FileLinker, LinkerEnvironment } from '@angular/compiler-cli/linker'; import type { AbsoluteFsPath, @@ -45,7 +45,34 @@ const noopFileSystem: ReadonlyFileSystem = { relative: (_from: string, to: string) => to, } as unknown as ReadonlyFileSystem; -let SHARED_LOGGER: ConsoleLogger; +const defaultLogger: Logger = { + level: 1, // LogLevel.info + debug(...args: string[]) { + if (this.level <= 0) { + // eslint-disable-next-line no-console + console.debug(...args); + } + }, + info(...args: string[]) { + if (this.level <= 1) { + // eslint-disable-next-line no-console + console.info(...args); + } + }, + warn(...args: string[]) { + if (this.level <= 2) { + // eslint-disable-next-line no-console + console.warn(...args); + } + }, + error(...args: string[]) { + if (this.level <= 3) { + // eslint-disable-next-line no-console + console.error(...args); + } + }, +}; + let SHARED_AST_HOST: OxcAstHost; let SHARED_DECLARATION_SCOPE: InlineDeclarationScope; @@ -56,14 +83,13 @@ export class OxcLinker { readonly #fileLinker: FileLinker; constructor(filename: string, code: string, jit = false) { - SHARED_LOGGER ??= new ConsoleLogger(LogLevel.info); SHARED_AST_HOST ??= new OxcAstHost(); SHARED_DECLARATION_SCOPE ??= new InlineDeclarationScope(); const astFactory = new StringAstFactory(code); const linkerEnvironment = LinkerEnvironment.create( noopFileSystem, - SHARED_LOGGER, + defaultLogger, SHARED_AST_HOST, astFactory, { linkerJitMode: jit, sourceMapping: false }, 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 index fa62c1c5ece1..467658eb1991 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -76,4 +76,27 @@ describe('oxc-linker', () => { expect(result.map?.version).toBe(3); expect(result.map?.sources).toContain('test.js'); }); + + it('should link ɵɵngDeclareClassMetadataAsync with parenthesized resolveMetadata return', () => { + const input = ` + import * as i0 from "@angular/core"; + export class DeferredFixture {} + i0.ɵɵngDeclareClassMetadataAsync({ + minVersion: "18.0.0", + version: "22.1.7", + ngImport: i0, + type: DeferredFixture, + resolveDeferredDeps: () => [], + resolveMetadata: () => ({ + decorators: [], + ctorParameters: null, + propDecorators: null, + }), + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('ɵsetClassMetadataAsync'); + expect(result.code).not.toContain('ɵɵngDeclareClassMetadataAsync'); + }); }); 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 00c82b328085..9a36eea56d61 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -20,14 +20,18 @@ import type { import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; +import { type PersistentCacheStore, createPersistentCacheStore } from '../../../utils/cache'; import { maxTransformWorkers, useTypeChecking } from '../../../utils/environment-options'; import { calculateHash, initializeHash } from '../../../utils/hash'; +import { + logCumulativeDurations, + profileAsync, + resetCumulativeDurations, +} from '../../../utils/profiling'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; -import { type PersistentCacheStore, createPersistentCacheStore } from '../cache'; -import { JavaScriptTransformer } from '../javascript-transformer'; +import { JavaScriptTransformer } from '../../javascript-transformer'; import { LoadResultCache, createCachedLoad } from '../load-result-cache'; -import { logCumulativeDurations, profileAsync, resetCumulativeDurations } from '../profiling'; import { AngularCompilationContext, PrimaryCompilationContext } from './compilation-state'; import { ComponentStylesheetBundler } from './component-stylesheets'; import { FileReferenceTracker } from './file-reference-tracker'; @@ -57,6 +61,7 @@ export interface CompilerPluginOptions { externalRuntimeStyles?: boolean; instrumentForCoverage?: (request: string) => boolean; templateUpdates?: Map; + rootFiles?: string[]; } // eslint-disable-next-line max-lines-per-function @@ -325,17 +330,21 @@ export function createCompilerPlugin( instrumentForCoverage: !!pluginOptions.instrumentForCoverage, includeTestMetadata: !!pluginOptions.includeTestMetadata, customConditions: build.initialOptions.conditions, + rootFiles: pluginOptions.rootFiles, }, ); + if (initializationResult.warnings?.length) { setupWarnings?.push(...initializationResult.warnings); } + angularCompilationContext.setCompilerOptions(initializationResult.compilerOptions); shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs; useTypeScriptTranspilation = !!initializationResult.compilerOptions['_useTypeScriptTranspilation']; referencedFiles = initializationResult.referencedFiles; externalStylesheets = initializationResult.externalStylesheets; + if (initializationResult.templateUpdates) { // Propagate any template updates initializationResult.templateUpdates.forEach((value, key) => 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 75ce354341ad..b5d2e453b57e 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -8,10 +8,10 @@ import assert from 'node:assert'; import path from 'node:path'; +import { MemoryCache } from '../../../utils/cache'; import { createContentHash } from '../../../utils/hash'; import { BundleContextResult, BundlerContext } from '../bundler-context'; import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; -import { MemoryCache } from '../cache'; import { MemoryLoadResultCache } from '../load-result-cache'; import { BundleStylesheetOptions, 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 eeb7045dfb61..991ad988662b 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -8,7 +8,7 @@ import type { Message, PartialMessage } from 'esbuild'; import { normalize } from 'node:path'; -import type { ChangedFiles } from '../../tools/esbuild/watcher'; +import type { ChangedFiles } from '../../utils/watcher'; import type { ComponentStylesheetBundler } from './angular/component-stylesheets'; import type { SourceFileCache } from './angular/source-file-cache'; import type { BundlerContext } from './bundler-context'; diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts index 04ce3ef94f4a..573b8616da60 100644 --- a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -28,6 +28,7 @@ export function createCompilerPluginOptions( externalRuntimeStyles, instrumentForCoverage, optimizationOptions, + rootFiles, } = options; const incremental = !!options.watch; @@ -45,5 +46,6 @@ export function createCompilerPluginOptions( instrumentForCoverage, templateUpdates, includeTestMetadata: !optimizationOptions.scripts, + rootFiles, }; } diff --git a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts index 8ffddf19adb7..b4c89cf644c1 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts @@ -7,136 +7,23 @@ */ import type { Plugin } from 'esbuild'; -import { readFile } from 'node:fs/promises'; import { createProjectResolver } from '../../utils/resolve-project'; - -/** - * The internal namespace used by generated locale import statements and Angular locale data plugin. - */ -export const LOCALE_DATA_NAMESPACE = 'angular:locale/data'; - -/** - * The base module location used to search for locale specific data. - */ -export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; - -/** - * Result of resolving locale data for a given locale tag. - */ -export interface LocaleDataResolution { - path?: string; - warning?: string; - error?: string; -} - -/** - * Result of loading locale data for a given locale tag. - */ -export interface LoadedLocaleData { - code?: string; - warning?: string; - error?: string; -} - -const localeDataCache = new Map>(); - -/** - * Resolves the path to the Angular locale data file for a given locale tag. - * - * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). - * @param projectResolve A function that attempts to resolve a path string to an absolute file path. - * @returns Resolution result with file path, or warning/error diagnostics if applicable. - */ -export function resolveLocaleDataPath( - rawLocaleTag: string, - projectResolve: (potentialPath: string) => string | undefined, -): LocaleDataResolution { - let partialLocaleTag: string; - try { - const locale = new Intl.Locale(rawLocaleTag); - partialLocaleTag = locale.baseName; - } catch { - return { - error: `Invalid or unsupported locale provided in configuration: "${rawLocaleTag}"`, - }; - } - - let exact = true; - while (partialLocaleTag) { - // Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here. - if (partialLocaleTag === 'en' || partialLocaleTag === 'en-US') { - return {}; - } - - const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocaleTag}`; - try { - const resolvedPath = projectResolve(potentialPath); - if (resolvedPath) { - return { - path: resolvedPath, - warning: exact - ? undefined - : `Locale data for '${rawLocaleTag}' cannot be found. Using locale data for '${partialLocaleTag}'.`, - }; - } - } catch {} - - // Remove the last subtag and try again with a less specific locale. - const parts = partialLocaleTag.split('-'); - partialLocaleTag = parts.slice(0, -1).join('-'); - exact = false; - } - - return { - warning: `Locale data for '${rawLocaleTag}' cannot be found. No locale data will be included for this locale.`, - }; -} - -/** - * Loads the Angular global locale data script for a specified locale tag. - * - * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). - * @param projectRoot Optional project root for module resolution. - * @returns A promise resolving to the loaded locale data script code and any diagnostic warnings. - */ -export function loadLocaleData( - rawLocaleTag: string, - projectRoot?: string, -): Promise { - let cached = localeDataCache.get(rawLocaleTag); - if (!cached) { - cached = (async () => { - const projectResolve = createProjectResolver(projectRoot ?? process.cwd()); - const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { - try { - return projectResolve(potentialPath); - } catch { - return undefined; - } - }); - - if (resolution.error) { - return { error: resolution.error }; - } - - if (resolution.path) { - try { - const code = await readFile(resolution.path, 'utf8'); - - return { code, warning: resolution.warning }; - } catch (e) { - return { error: `Failed to read locale data file: ${(e as Error).message}` }; - } - } - - return { warning: resolution.warning }; - })(); - - localeDataCache.set(rawLocaleTag, cached); - } - - return cached; -} +import { + LOCALE_DATA_NAMESPACE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; + +export { + LOCALE_DATA_NAMESPACE, + LOCALE_DATA_BASE_MODULE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; /** * Creates an esbuild plugin that resolves Angular locale data files from `@angular/common`. 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 index 97f7d5cbe1c6..c5dcee90ccf9 100644 --- a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -31,8 +31,9 @@ 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 type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { mapConcurrent, runConcurrent } from '../../utils/concurrency'; import { calculateHash, createContentHash } from '../../utils/hash'; -import type { Cache as PersistentCacheStore } from './cache'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; /** @@ -114,29 +115,6 @@ export function extractDiskFilePath(path: string): string | 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. @@ -214,7 +192,7 @@ async function computeMetadataForWatchFiles( ): Promise> { const watchFilesMetadata: Record = {}; - await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { try { const knownContent = knownContents?.get(filePath); const [content, stats] = await Promise.all([ 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 index f8ecc0611818..b41add55031d 100644 --- 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 @@ -10,8 +10,8 @@ import type { OnLoadResult } from 'esbuild'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; import { initializeHash } from '../../utils/hash'; -import type { Cache as PersistentCacheStore } from './cache'; import { type CachedLoadResultEntry, PersistentLoadResultCache, diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts index 7fa20dde64ae..bb5c945bab7d 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts @@ -22,6 +22,7 @@ export interface BundleStylesheetOptions { workspaceRoot: string; optimization: boolean; inlineFonts: boolean; + dataurl?: boolean; preserveSymlinks?: boolean; sourcemap: boolean | 'external' | 'inline' | 'linked'; sourcesContent?: boolean; @@ -62,7 +63,7 @@ export function createStylesheetBundleOptions( pluginFactory.create(SassStylesheetLanguage), pluginFactory.create(LessStylesheetLanguage), pluginFactory.create(CssStylesheetLanguage), - createCssResourcePlugin(cache), + createCssResourcePlugin(cache, options.dataurl), ]; if (options.inlineFonts) { diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts index 7f83e7dc7a8f..ced6422c3fd3 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts @@ -25,9 +25,11 @@ const CSS_RESOURCE_RESOLUTION = Symbol('CSS_RESOURCE_RESOLUTION'); * and types to be supported without needing to manually specify all extensions * within the build configuration. * + * @param cache An optional load result cache. + * @param dataurl If true, resources will be loaded with the 'dataurl' loader to inline them as base64 data URIs. * @returns An esbuild {@link Plugin} instance. */ -export function createCssResourcePlugin(cache?: LoadResultCache): Plugin { +export function createCssResourcePlugin(cache?: LoadResultCache, dataurl?: boolean): Plugin { return { name: 'angular-css-resource', setup(build: PluginBuild): void { @@ -119,7 +121,7 @@ export function createCssResourcePlugin(cache?: LoadResultCache): Plugin { return { contents: await readFile(resourcePath), - loader: 'file', + loader: dataurl ? 'dataurl' : 'file', watchFiles: [resourcePath], }; }), diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts index cce85f61b9a2..432ae7ba79b8 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -9,9 +9,9 @@ import type { OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { CanonicalizeContext, CompileResult, Exception, Syntax } from 'sass-embedded'; +import type { CompileResult, Exception, Syntax } from 'sass-embedded'; +import { MemoryCache } from '../../../utils/cache'; import type { SassCompiler } from '../../sass/sass-service'; -import { MemoryCache } from '../cache'; import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; let sassService: SassCompiler | undefined; @@ -50,12 +50,7 @@ export const SassStylesheetLanguage = Object.freeze({ fileFilter: /\.s[ac]ss$/, process(data, file, format, options, build) { const syntax = format === 'sass' ? 'indented' : 'scss'; - const resolveUrl = async (url: string, options: CanonicalizeContext) => { - let resolveDir = build.initialOptions.absWorkingDir; - if (options.containingUrl) { - resolveDir = dirname(fileURLToPath(options.containingUrl)); - } - + const resolveUrl = async (url: string, resolveDir: string | undefined) => { const path = url.startsWith('pkg:') ? url.slice(4) : url; const result = await build.resolve(path, { kind: 'import-rule', @@ -65,7 +60,14 @@ export const SassStylesheetLanguage = Object.freeze({ return result; }; - return compileString(data, file, syntax, options, resolveUrl); + return compileString( + data, + file, + syntax, + options, + resolveUrl, + build.initialOptions.absWorkingDir, + ); }, }); @@ -97,12 +99,35 @@ function parsePackageName(url: string): { packageName: string; readonly pathSegm }; } +/** + * Returns the scope that qualifies the cached package resolutions of a stylesheet. A stylesheet + * within `node_modules` uses the root of its enclosing package, since every file of a package + * resolves its dependencies against the same `node_modules` directories. All other stylesheets use + * the working directory, allowing component stylesheets to share package resolutions. + */ +export function getPackageScope( + containingPath: string | undefined, + workingDirectory: string | undefined, +): string { + // The directory segments of the stylesheet, excluding the file name + const segments = containingPath?.split(/[\\/]/).slice(0, -1) ?? []; + const index = segments.lastIndexOf('node_modules'); + if (index === -1) { + return workingDirectory ?? ''; + } + + const packageNameLength = segments[index + 1]?.[0] === '@' ? 2 : 1; + + return segments.slice(0, index + 1 + packageNameLength).join('/'); +} + async function compileString( data: string, filePath: string, syntax: Syntax, options: StylesheetPluginOptions, - resolveUrl: (url: string, options: CanonicalizeContext) => Promise, + resolveUrl: (url: string, resolveDir: string | undefined) => Promise, + workingDirectory: string | undefined, ): Promise { // Lazily load Sass when a Sass file is found if (sassService === undefined) { @@ -119,7 +144,8 @@ async function compileString( } // Caching follows Sass behavior where a given package url will always resolve to the same value - // regardless of its importer's path. Relative paths are qualified with the containing URL. + // regardless of its importer's path, except for importers within `node_modules`, which are + // scoped to their enclosing package. Relative paths are qualified with the containing URL. // A null value indicates that the cached resolution attempt failed to find a location and // later stage resolution should be attempted. This avoids potentially expensive repeat // failing resolution attempts. @@ -145,11 +171,19 @@ async function compileString( importers: [ { findFileUrl: (url, options) => { + const containingPath = + options.containingUrl?.protocol === 'file:' + ? fileURLToPath(options.containingUrl) + : undefined; + const resolveDir = containingPath ? dirname(containingPath) : workingDirectory; const isPackage = isPackageUrl(url); - const cacheKey = isPackage ? url : `${options.containingUrl?.href ?? ''}:${url}`; + const scope = getPackageScope(containingPath, workingDirectory); + const cacheKey = isPackage + ? `${scope}:${url}` + : `${options.containingUrl?.href ?? ''}:${url}`; return currentResolutionCache.getOrCreate(cacheKey, async () => { - const result = await resolveUrl(url, options); + const result = await resolveUrl(url, resolveDir); if (result.path) { return pathToFileURL(result.path); } @@ -164,10 +198,10 @@ async function compileString( // Caching package root locations is particularly beneficial for `@material/*` packages // which extensively use deep imports. const packageRoot = await currentPackageRootCache.getOrCreate( - packageName, + `${scope}:${packageName}`, async () => { // Use the required presence of a package root `package.json` file to resolve the location - const packageResult = await resolveUrl(packageName + '/package.json', options); + const packageResult = await resolveUrl(packageName + '/package.json', resolveDir); return packageResult.path ? dirname(packageResult.path) : null; }, diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts index 7be104741caa..cdd4d7e7c8db 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts @@ -6,7 +6,20 @@ * found in the LICENSE file at https://angular.dev/license */ -import { isPackageUrl } from './sass-language'; +import type { PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { statSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { SassCompiler } from '../../sass/sass-service'; +import { + SassStylesheetLanguage, + getPackageScope, + isPackageUrl, + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from './sass-language'; describe('sass-language', () => { describe('isPackageUrl', () => { @@ -46,4 +59,232 @@ describe('sass-language', () => { expect(isPackageUrl('')).toBeFalse(); }); }); + + describe('getPackageScope', () => { + const scope = (containingPath?: string) => getPackageScope(containingPath, '/app'); + + it('should use the working directory for a stylesheet outside node_modules', () => { + expect(scope('/app/src/app.scss')).toBe('/app'); + expect(scope('/app/src/foo-node_modules/a.scss')).toBe('/app'); + expect(scope('/app/src/node_modules.scss')).toBe('/app'); + expect(scope(undefined)).toBe('/app'); + }); + + it('should use the enclosing package root for a stylesheet within node_modules', () => { + expect(scope('/app/node_modules/pkg/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/pkg/sub/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/@scope/pkg/sub/a.scss')).toBe('/app/node_modules/@scope/pkg'); + expect(scope('/app/node_modules/foo-node_modules/a.scss')).toBe( + '/app/node_modules/foo-node_modules', + ); + expect(scope('/app/node_modules/a.scss')).toBe('/app/node_modules'); + }); + + it('should use the innermost package root for a nested dependency', () => { + expect(scope('/app/node_modules/dep/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/dep/node_modules/pkg', + ); + expect(scope('/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg', + ); + }); + + it('should support Windows path separators', () => { + expect(getPackageScope('C:\\app\\node_modules\\pkg\\sub\\a.scss', 'C:\\app')).toBe( + 'C:/app/node_modules/pkg', + ); + expect(getPackageScope('C:\\app\\src\\app.scss', 'C:\\app')).toBe('C:\\app'); + }); + }); + + describe('package resolution caching', () => { + let temporaryRoot: string; + let projectRoot: string; + let buttonStylesheet: string; + let cardStylesheet: string; + let dependencyStylesheet: string; + let resolveRequests: string[]; + + /** + * Creates a build stub that resolves a package specifier by searching the `node_modules` + * directories visible from the resolve directory, which is how esbuild resolves the + * package specifiers of a stylesheet. + */ + function createBuildStub(): PluginBuild { + return { + initialOptions: { absWorkingDir: projectRoot }, + resolve: async (path: string, options: { resolveDir: string }) => { + resolveRequests.push(`${options.resolveDir}:${path}`); + + for (let directory = options.resolveDir; ; directory = dirname(directory)) { + // A package specifier resolves to the index file of the package, and an explicit file + // within it to that file. A deeper subpath is left unresolved, as esbuild leaves one + // that the `exports` of the package does not name; the Sass importer then resolves it + // against the package root instead. + for (const candidate of [ + join(directory, 'node_modules', path, '_index.scss'), + join(directory, 'node_modules', path), + ]) { + if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) { + return { path: candidate, errors: [], warnings: [] }; + } + } + + if (dirname(directory) === directory) { + return { path: undefined, errors: [], warnings: [] }; + } + } + }, + } as unknown as PluginBuild; + } + + async function compile(stylesheet: string, source = "@use 'theme';"): Promise { + const result = await SassStylesheetLanguage.process?.( + source, + stylesheet, + 'scss', + { sourcemap: false }, + createBuildStub(), + ); + if (!result) { + throw new Error('The Sass stylesheet language has no process function.'); + } + + if (result.errors?.length) { + return `error: ${result.errors[0].text}`; + } + + return (result.contents as string).trim(); + } + + async function writePackage(directory: string, marker: string): Promise { + await mkdir(join(directory, 'sub'), { recursive: true }); + await writeFile(join(directory, 'package.json'), '{}'); + await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`); + await writeFile( + join(directory, 'sub', '_other.scss'), + `.deep { content: "${marker} deep"; }`, + ); + } + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + temporaryRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-sass-language-')); + projectRoot = join(temporaryRoot, 'project'); + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + + // An application using a `theme` package, and a dependency with its own nested version of + // `theme` plus an `extra` package that only the dependency can see. + await writePackage(join(projectRoot, 'node_modules', 'theme'), 'project'); + await writePackage(join(dependencyRoot, 'node_modules', 'theme'), 'dependency'); + await writePackage(join(dependencyRoot, 'node_modules', 'extra'), 'extra'); + + buttonStylesheet = join(projectRoot, 'src', 'app', 'button', 'button.scss'); + cardStylesheet = join(projectRoot, 'src', 'app', 'card', 'card.scss'); + dependencyStylesheet = join(dependencyRoot, 'styles.scss'); + for (const stylesheet of [buttonStylesheet, cardStylesheet]) { + await mkdir(dirname(stylesheet), { recursive: true }); + } + }); + + afterAll(async () => { + shutdownSassWorkerPool(); + await rm(temporaryRoot, { force: true, recursive: true }); + }); + + beforeEach(() => { + resetSassWorkerPoolCaches(); + resolveRequests = []; + }); + + it('should not use the package resolution of a dependency for the application', async () => { + const dependency = await compile(dependencyStylesheet); + const application = await compile(buttonStylesheet); + + expect(dependency).toContain('content: "dependency";'); + expect(application).toContain('content: "project";'); + }); + + it('should not use the package resolution of the application for a dependency', async () => { + const application = await compile(buttonStylesheet); + const dependency = await compile(dependencyStylesheet); + + expect(application).toContain('content: "project";'); + expect(dependency).toContain('content: "dependency";'); + }); + + it('should not reuse a failed package resolution of the application for a dependency', async () => { + const source = "@use 'extra';"; + const application = await compile(buttonStylesheet, source); + const dependency = await compile(dependencyStylesheet, source); + + expect(application).toContain("Can't find stylesheet to import."); + expect(dependency).toContain('content: "extra";'); + }); + + it('should not use the package root of a dependency for a deep import of the application', async () => { + // A subpath that resolves to no file of its own is located through the root of the package, + // which is cached separately from the resolution of the specifier. + const source = "@use 'theme/sub/other';"; + const dependency = await compile(dependencyStylesheet, source); + const application = await compile(buttonStylesheet, source); + + expect(dependency).toContain('content: "dependency deep";'); + expect(application).toContain('content: "project deep";'); + }); + + it('should share a package resolution between the stylesheets of different components', async () => { + const button = await compile(buttonStylesheet); + const card = await compile(cardStylesheet); + + expect(button).toContain('content: "project";'); + expect(card).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a dependency', async () => { + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + const first = await compile(join(dependencyRoot, 'sub1', 'styles.scss')); + const second = await compile(join(dependencyRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "dependency";'); + expect(second).toContain('content: "dependency";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a scoped package', async () => { + const packageRoot = join(projectRoot, 'node_modules', '@scope', 'pkg'); + const first = await compile(join(packageRoot, 'sub1', 'styles.scss')); + const second = await compile(join(packageRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "project";'); + expect(second).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should resolve a package url of a non-file containing URL from the working directory', async () => { + // The stylesheets of a build have file URLs, but Sass does not limit a containing URL to them. + spyOn(SassCompiler.prototype, 'compileStringAsync').and.callFake(async (_, options) => { + const importer = options.importers?.[0] as { + findFileUrl( + url: string, + context: { containingUrl: URL; fromImport: boolean }, + ): Promise; + }; + const url = await importer.findFileUrl('theme', { + containingUrl: new URL('custom:styles.scss'), + fromImport: false, + }); + + return { css: url?.href ?? '', loadedUrls: [] }; + }); + + const result = await compile(buttonStylesheet); + + expect(result).toBe( + pathToFileURL(join(projectRoot, 'node_modules', 'theme', '_index.scss')).href, + ); + }); + }); }); 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 index 22397ab189b3..d760a14197ec 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts @@ -26,6 +26,7 @@ import type { BundleStylesheetOptions } from './bundle-options'; * - `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. + * - `dataurl`: Controls whether referenced assets are inlined as base64 data URIs. * - `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. @@ -56,6 +57,7 @@ export function calculateGlobalStylesheetConfigHash( publicPath: options.publicPath, outputNames: options.outputNames, inlineFonts: options.inlineFonts, + dataurl: options.dataurl ?? false, preserveSymlinks: options.preserveSymlinks, externalDependencies: options.externalDependencies, postcssConfig: options.postcssConfiguration?.configPath 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 index c524a2d0c36b..49b232720fbf 100644 --- 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 @@ -70,5 +70,20 @@ describe('Stylesheet Global Config Hash', () => { ); expect(hash1).not.toBe(hash2); }); + + it('should produce different hashes when dataurl changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions, dataurl: true }, '1.0.0'); + expect(hash1).not.toBe(hash2); + }); + + it('should produce the same hash when dataurl is undefined or false', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, dataurl: false }, + '1.0.0', + ); + expect(hash1).toBe(hash2); + }); }); }); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts similarity index 88% rename from packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts rename to packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts index ac75ab246d8d..66eaf23ca974 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -6,16 +6,19 @@ * found in the LICENSE file at https://angular.dev/license */ -import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; -import type { ɵParsedTranslation } from '@angular/localize'; -import type { Node } from '@oxc-project/types'; +import { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import { + type ɵParsedTranslation, + ɵisMissingTranslationError, + ɵmakeTemplateObject, + ɵtranslate, +} from '@angular/localize'; import { MagicString } from 'magic-string'; -import assert from 'node:assert'; import { deserialize } from 'node:v8'; import { parseSync } from 'oxc-parser'; import { traversePostOrder } from '../oxc/traversal'; -import { loadLocaleData } from './i18n-locale-plugin'; import { createSharedTranslationProxy } from './i18n-translation-reader'; +import { loadLocaleData } from './locale-data'; /** * The options passed to the inliner for each code request @@ -153,6 +156,8 @@ const deserializedTranslations = new Map, + messageParts: TemplateStringsArray, + substitutions: readonly number[], + missingTranslation: 'error' | 'warning' | 'ignore', +): [TemplateStringsArray, readonly number[]] { + try { + return ɵtranslate(translations, messageParts, substitutions) as [ + TemplateStringsArray, + readonly number[], + ]; + } catch (error) { + if (ɵisMissingTranslationError(error)) { + if (missingTranslation !== 'ignore') { + diagnostics.push({ type: missingTranslation, message: error.message }); + } -/** - * Attempts to load the `@angular/localize/tools` module containing the functionality to - * perform the file translations. - * This module must be dynamically loaded as it is an ESM module and this file is CommonJS. - */ -async function loadLocalizeTools(): Promise { - // Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround. - // Once TypeScript provides support for keeping the dynamic import this workaround can be - // changed to a direct dynamic import. - localizeToolsModule ??= await import('@angular/localize/tools'); + return [ + ɵmakeTemplateObject(error.parsedMessage.messageParts, error.parsedMessage.messageParts), + substitutions, + ]; + } else { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ type: 'error', message }); - return localizeToolsModule; + return [messageParts, substitutions]; + } + } } /** @@ -467,21 +484,20 @@ async function inlineLocalize( missingTranslation: 'error' | 'warning' | 'ignore' = 'warning', ) { const magicString = new MagicString(code); - const { Diagnostics, translate } = await loadLocalizeTools(); - const diagnostics = new Diagnostics(); + const diagnostics: DiagnosticMessage[] = []; if (metadata.diagnostics) { for (const message of metadata.diagnostics) { - diagnostics.error(message); + diagnostics.push({ type: 'error', message }); } } if (metadata.localeInsertSites.length > 0) { const localeData = await loadLocaleData(locale); if (localeData.error) { - diagnostics.error(localeData.error); + diagnostics.push({ type: 'error', message: localeData.error }); } else if (localeData.warning) { - diagnostics.warn(localeData.warning); + diagnostics.push({ type: 'warning', message: localeData.warning }); } let injected = false; for (const site of metadata.localeInsertSites) { @@ -495,7 +511,7 @@ async function inlineLocalize( } for (const callSite of metadata.callSites) { - const [translatedParts, translatedSubstitutions] = translate( + const [translatedParts, translatedSubstitutions] = translateMessage( diagnostics, translation || {}, callSite.messageParts, @@ -544,6 +560,7 @@ async function inlineLocalize( includeContent: true, hires: 'boundary', }); + remapping ??= (await import('@ampproject/remapping')).default; outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null); } diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/i18n/i18n-inliner.ts similarity index 99% rename from packages/angular/build/src/tools/esbuild/i18n-inliner.ts rename to packages/angular/build/src/tools/i18n/i18n-inliner.ts index e5ec68f48e26..581a2477adc5 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner.ts @@ -11,10 +11,18 @@ import assert from 'node:assert'; import { createRequire } from 'node:module'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; +import { + type Cache, + type PersistentCacheStore, + createPersistentCacheStore, +} from '../../utils/cache'; import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; import { WorkerPool } from '../../utils/worker-pool'; -import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; -import { type Cache, type PersistentCacheStore, createPersistentCacheStore } from './cache'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; import type { InlineCodeRequest, InlineCodeResult, diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts similarity index 99% rename from packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts rename to packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts index 8f73c018e4bd..bbc827b94c7f 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts @@ -11,10 +11,14 @@ import { transform } from 'esbuild'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { createPersistentCacheStore } from '../../utils/cache'; import { initializeHash } from '../../utils/hash'; import { WorkerPool } from '../../utils/worker-pool'; -import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; -import { createPersistentCacheStore } from './cache'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; import { I18nInliner, type I18nInlinerOptions } from './i18n-inliner'; /** diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts rename to packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts rename to packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts rename to packages/angular/build/src/tools/i18n/i18n-translation-reader.ts diff --git a/packages/angular/build/src/tools/i18n/index.ts b/packages/angular/build/src/tools/i18n/index.ts new file mode 100644 index 000000000000..a2f8cdd2d8f3 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * @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 * from './i18n-inliner'; +export * from './i18n-translation-encoder'; +export * from './i18n-translation-reader'; +export * from './locale-data'; diff --git a/packages/angular/build/src/tools/i18n/locale-data.ts b/packages/angular/build/src/tools/i18n/locale-data.ts new file mode 100644 index 000000000000..0418830b69a8 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/locale-data.ts @@ -0,0 +1,138 @@ +/** + * @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 { readFile } from 'node:fs/promises'; +import { createProjectResolver } from '../../utils/resolve-project'; + +/** + * The internal namespace used by generated locale import statements and Angular locale data plugin. + */ +export const LOCALE_DATA_NAMESPACE = 'angular:locale/data'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Result of resolving locale data for a given locale tag. + */ +export interface LocaleDataResolution { + path?: string; + warning?: string; + error?: string; +} + +/** + * Result of loading locale data for a given locale tag. + */ +export interface LoadedLocaleData { + code?: string; + warning?: string; + error?: string; +} + +const localeDataCache = new Map>(); + +/** + * Resolves the path to the Angular locale data file for a given locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectResolve A function that attempts to resolve a path string to an absolute file path. + * @returns Resolution result with file path, or warning/error diagnostics if applicable. + */ +export function resolveLocaleDataPath( + rawLocaleTag: string, + projectResolve: (potentialPath: string) => string | undefined, +): LocaleDataResolution { + let partialLocaleTag: string; + try { + const locale = new Intl.Locale(rawLocaleTag); + partialLocaleTag = locale.baseName; + } catch { + return { + error: `Invalid or unsupported locale provided in configuration: "${rawLocaleTag}"`, + }; + } + + let exact = true; + while (partialLocaleTag) { + // Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here. + if (partialLocaleTag === 'en' || partialLocaleTag === 'en-US') { + return {}; + } + + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocaleTag}`; + try { + const resolvedPath = projectResolve(potentialPath); + if (resolvedPath) { + return { + path: resolvedPath, + warning: exact + ? undefined + : `Locale data for '${rawLocaleTag}' cannot be found. Using locale data for '${partialLocaleTag}'.`, + }; + } + } catch {} + + // Remove the last subtag and try again with a less specific locale. + const parts = partialLocaleTag.split('-'); + partialLocaleTag = parts.slice(0, -1).join('-'); + exact = false; + } + + return { + warning: `Locale data for '${rawLocaleTag}' cannot be found. No locale data will be included for this locale.`, + }; +} + +/** + * Loads the Angular global locale data script for a specified locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectRoot Optional project root for module resolution. + * @returns A promise resolving to the loaded locale data script code and any diagnostic warnings. + */ +export function loadLocaleData( + rawLocaleTag: string, + projectRoot?: string, +): Promise { + let cached = localeDataCache.get(rawLocaleTag); + if (!cached) { + cached = (async () => { + const projectResolve = createProjectResolver(projectRoot ?? process.cwd()); + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { error: resolution.error }; + } + + if (resolution.path) { + try { + const code = await readFile(resolution.path, 'utf8'); + + return { code, warning: resolution.warning }; + } catch (e) { + return { error: `Failed to read locale data file: ${(e as Error).message}` }; + } + } + + return { warning: resolution.warning }; + })(); + + localeDataCache.set(rawLocaleTag, cached); + } + + return cached; +} diff --git a/packages/angular/build/src/tools/javascript-transformer/index.ts b/packages/angular/build/src/tools/javascript-transformer/index.ts new file mode 100644 index 000000000000..399eea87e644 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/index.ts @@ -0,0 +1,9 @@ +/** + * @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 * from './javascript-transformer'; diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts similarity index 97% rename from packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts rename to packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts index 99ef235f3279..87df19ee81eb 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import remapping, { type DecodedSourceMap, type EncodedSourceMap } from '@ampproject/remapping'; +import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping'; import type { PluginItem } from '@babel/core'; import { createRequire } from 'node:module'; import { workerData } from 'node:worker_threads'; @@ -52,6 +52,8 @@ let babelLinkerDeps: > | undefined; +let remapping: typeof import('@ampproject/remapping').default | undefined; + const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); @@ -253,6 +255,7 @@ async function transformJavaScriptImpl( } if (remappingChain.length > 0) { + remapping ??= (await import('@ampproject/remapping')).default; const finalMap = remapping(remappingChain, () => null).toString(); const base64Map = Buffer.from(finalMap).toString('base64'); code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts similarity index 99% rename from packages/angular/build/src/tools/esbuild/javascript-transformer.ts rename to packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts index 2917222a3277..56a7b4eb6ed9 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts @@ -7,11 +7,11 @@ */ import { readFile } from 'node:fs/promises'; +import { Cache } from '../../utils/cache'; 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'); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts rename to packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts 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 02f54756eaff..3c38d000b30c 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -98,7 +98,7 @@ export function createAngularAssetsMiddleware( // Resource files are handled directly. // Global stylesheets (CSS files) are currently considered resources to workaround // dev server sourcemap issues with stylesheets. - if (extension !== '.js' && extension !== '.html') { + if (extension !== '.js' && pathname !== '/index.html' && pathname !== '/index.csr.html') { const outputFile = outputFiles.get(pathname); if (outputFile?.servable) { let data: Uint8Array | string = outputFile.contents; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index 78458624c4db..c75c63e992bb 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -21,6 +21,7 @@ import { export function createAngularSsrInternalMiddleware( server: ViteDevServer, + resetComponentUpdates: () => void, indexHtmlTransformer?: (content: string) => Promise, ): Connect.NextHandleFunction { let cachedAngularServerApp: ReturnType | undefined; @@ -53,6 +54,7 @@ export function createAngularSsrInternalMiddleware( // Only Add the transform hook only if it's a different instance. if (cachedAngularServerApp !== angularServerApp) { angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); const processedHtml = await server.transformIndexHtml(url.pathname, html); return indexHtmlTransformer?.(processedHtml) ?? processedHtml; @@ -76,13 +78,13 @@ export function createAngularSsrInternalMiddleware( export async function createAngularSsrExternalMiddleware( server: ViteDevServer, + resetComponentUpdates: () => void, indexHtmlTransformer?: (content: string) => Promise, ): Promise { let fallbackWarningShown = false; let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; let angularSsrInternalMiddleware: - | ReturnType - | undefined; + ReturnType | undefined; // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, // which must be processed by the runtime linker, even if they are not used. @@ -120,6 +122,7 @@ export async function createAngularSsrExternalMiddleware( angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware( server, + resetComponentUpdates, indexHtmlTransformer, ); @@ -132,6 +135,7 @@ export async function createAngularSsrExternalMiddleware( AngularAppEngine.ɵdisableAllowedHostsCheck = disableAllowedHostsCheck; AngularAppEngine.ɵallowStaticRouteRender = true; AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); const processedHtml = await server.transformIndexHtml(url.pathname, html); return indexHtmlTransformer?.(processedHtml) ?? processedHtml; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts new file mode 100644 index 000000000000..a61011043a43 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts @@ -0,0 +1,130 @@ +/** + * @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 { IncomingMessage, ServerResponse } from 'node:http'; +import { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; + +type ViteDevServer = Parameters[0]; + +describe('SSR dev-server middleware', () => { + function createMockReqRes(): { req: IncomingMessage; res: ServerResponse } { + const req = { + url: '/', + method: 'GET', + headers: { host: 'localhost' }, + } as unknown as IncomingMessage; + + const res = { + statusCode: 200, + setHeader: jasmine.createSpy('setHeader'), + writeHead: jasmine.createSpy('writeHead'), + end: jasmine.createSpy('end'), + } as unknown as ServerResponse; + + return { req, res }; + } + + it('resets component updates when html:transform:pre executes in internal SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + const fakeAngularServerApp = { + hooks: { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }, + handle: jasmine.createSpy('handle').and.resolveTo(new Response('rendered')), + }; + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + ɵgetOrCreateAngularServerApp: () => fakeAngularServerApp, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html), + } as unknown as ViteDevServer; + + const middleware = createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + async (html) => html + '', + ); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); + + it('resets component updates when html:transform:pre executes in external SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + class FakeAngularAppEngine { + static ɵhooks = { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }; + static ɵdisableAllowedHostsCheck: boolean; + static ɵallowStaticRouteRender: boolean; + } + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + reqHandler: Object.assign(() => new Response('ok'), { __ng_request_handler__: true }), + AngularAppEngine: FakeAngularAppEngine, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html + ''), + } as unknown as ViteDevServer; + + const middleware = await createAngularSsrExternalMiddleware(server, resetComponentUpdates); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts index 78e16304f01d..34b0704f4091 100644 --- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -117,13 +117,21 @@ export function createAngularSetupMiddlewaresPlugin( if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) { patchBaseMiddleware(server.middlewares, server.config.base); - middlewares.use(await createAngularSsrExternalMiddleware(server, indexHtmlTransformer)); + middlewares.use( + await createAngularSsrExternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); return; } if (ssrMode === ServerSsrMode.InternalSsrMiddleware) { - middlewares.use(createAngularSsrInternalMiddleware(server, indexHtmlTransformer)); + middlewares.use( + createAngularSsrInternalMiddleware(server, resetComponentUpdates, indexHtmlTransformer), + ); } middlewares.use(angularHtmlFallbackMiddleware); diff --git a/packages/angular/build/src/tools/vite/utils.ts b/packages/angular/build/src/tools/vite/utils.ts index dc7094b8ffe4..fa833066f8ed 100644 --- a/packages/angular/build/src/tools/vite/utils.ts +++ b/packages/angular/build/src/tools/vite/utils.ts @@ -13,7 +13,7 @@ import type { DepOptimizationConfig } from 'vite' with { 'resolution-mode': 'import', }; import type { ExternalResultMetadata } from '../esbuild/bundler-execution-result'; -import { JavaScriptTransformer } from '../esbuild/javascript-transformer'; +import { JavaScriptTransformer } from '../javascript-transformer'; export type AngularMemoryOutputFiles = Map< string, diff --git a/packages/angular/build/src/tools/esbuild/cache.ts b/packages/angular/build/src/utils/cache/cache.ts similarity index 98% rename from packages/angular/build/src/tools/esbuild/cache.ts rename to packages/angular/build/src/utils/cache/cache.ts index 40c6787abee3..1773caca71ea 100644 --- a/packages/angular/build/src/tools/esbuild/cache.ts +++ b/packages/angular/build/src/utils/cache/cache.ts @@ -11,8 +11,8 @@ * Provides infrastructure for common caching functionality within the build system. */ -import { persistentCacheStoreSetting } from '../../utils/environment-options'; -import { assertIsError } from '../../utils/error'; +import { persistentCacheStoreSetting } from '../environment-options'; +import { assertIsError } from '../error'; /** * A backing data store for one or more Cache instances. diff --git a/packages/angular/build/src/tools/esbuild/cache_spec.ts b/packages/angular/build/src/utils/cache/cache_spec.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/cache_spec.ts rename to packages/angular/build/src/utils/cache/cache_spec.ts diff --git a/packages/angular/build/src/utils/cache/index.ts b/packages/angular/build/src/utils/cache/index.ts new file mode 100644 index 000000000000..417d13326f50 --- /dev/null +++ b/packages/angular/build/src/utils/cache/index.ts @@ -0,0 +1,9 @@ +/** + * @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 * from './cache'; diff --git a/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts b/packages/angular/build/src/utils/cache/lmdb-cache-store.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts rename to packages/angular/build/src/utils/cache/lmdb-cache-store.ts diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts rename to packages/angular/build/src/utils/cache/sqlite-cache-store.ts diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts similarity index 100% rename from packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts rename to packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts diff --git a/packages/angular/build/src/utils/concurrency.ts b/packages/angular/build/src/utils/concurrency.ts new file mode 100644 index 000000000000..a38a9a57fa36 --- /dev/null +++ b/packages/angular/build/src/utils/concurrency.ts @@ -0,0 +1,93 @@ +/** + * @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 + */ + +/** + * Executes an asynchronous function for each item in an array concurrently up to a specified limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to process. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async task function. + */ +export async function runConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return; + } + + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } +} + +/** + * Maps an array asynchronously with a sliding worker pool up to a specified concurrency limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to map. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async mapper function. + * @returns Array of mapped results in the original item order. + */ +export async function mapConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const results: R[] = new Array(items.length); + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + results[i] = await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } + + return results; +} diff --git a/packages/angular/build/src/utils/concurrency_spec.ts b/packages/angular/build/src/utils/concurrency_spec.ts new file mode 100644 index 000000000000..ab63054046c5 --- /dev/null +++ b/packages/angular/build/src/utils/concurrency_spec.ts @@ -0,0 +1,212 @@ +/** + * @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 { mapConcurrent, runConcurrent } from './concurrency'; + +describe('concurrency utilities', () => { + describe('runConcurrent', () => { + it('should process all items in an array', async () => { + const items = [1, 2, 3, 4, 5]; + const processed: number[] = []; + + await runConcurrent(items, 2, async (item) => { + processed.push(item); + }); + + expect(processed.sort((a, b) => a - b)).toEqual(items); + }); + + it('should respect the concurrency limit', async () => { + const items = [10, 20, 30, 40, 50, 60]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await runConcurrent(items, limit, async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + const processed: number[] = []; + + await runConcurrent(items, 2.7, async (item) => { + processed.push(item); + }); + expect(processed).toEqual(items); + + const processedNaN: number[] = []; + await runConcurrent(items, NaN, async (item) => { + processedNaN.push(item); + }); + expect(processedNaN).toEqual(items); + }); + + it('should handle an empty array', async () => { + let called = false; + await runConcurrent([], 3, async () => { + called = true; + }); + + expect(called).toBe(false); + }); + + it('should pass item and index to callback', async () => { + const items = ['a', 'b', 'c']; + const passed: { item: string; index: number }[] = []; + + await runConcurrent(items, 2, async (item, index) => { + passed.push({ item, index }); + }); + + expect(passed.sort((a, b) => a.index - b.index)).toEqual([ + { item: 'a', index: 0 }, + { item: 'b', index: 1 }, + { item: 'c', index: 2 }, + ]); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + runConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Task failed'); + } + }), + ).toBeRejectedWithError('Task failed'); + + // Subsequent items should not have been executed + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + runConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Task 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + }), + ).toBeRejectedWithError('Task 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); + + describe('mapConcurrent', () => { + it('should map items and return results in original order', async () => { + const items = [1, 2, 3, 4, 5]; + + const results = await mapConcurrent(items, 2, async (item) => { + // Add varying delay so tasks finish out of order + await new Promise((resolve) => setTimeout(resolve, (5 - item) * 5)); + + return item * 2; + }); + + expect(results).toEqual([2, 4, 6, 8, 10]); + }); + + it('should respect the concurrency limit', async () => { + const items = [1, 2, 3, 4, 5]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await mapConcurrent(items, limit, async (item) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + + return item; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + + const results = await mapConcurrent(items, 2.7, async (item) => item * 2); + expect(results).toEqual([2, 4, 6]); + + const resultsNaN = await mapConcurrent(items, NaN, async (item) => item * 2); + expect(resultsNaN).toEqual([2, 4, 6]); + }); + + it('should handle an empty array', async () => { + const results = await mapConcurrent([], 3, async (item) => item); + + expect(results).toEqual([]); + }); + + it('should pass item and index to mapper function', async () => { + const items = ['x', 'y', 'z']; + + const results = await mapConcurrent(items, 2, async (item, index) => `${item}:${index}`); + + expect(results).toEqual(['x:0', 'y:1', 'z:2']); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + mapConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Map failed'); + } + + return item; + }), + ).toBeRejectedWithError('Map failed'); + + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + mapConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Map 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + + return item; + }), + ).toBeRejectedWithError('Map 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 29faf3e7bbae..d87c104aba65 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -141,6 +141,15 @@ export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - export const maxTransformWorkers = customMaxWorkers ?? Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); +/** + * The maximum number of workers to use for i18n translation inlining. + * Translation inlining and sourcemap remapping are CPU- and memory-intensive operations. + * To prevent thread oversubscription, memory allocator lock contention, and high-core + * performance degradation, concurrency is capped at 8 unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxInlinerWorkers = customMaxWorkers ?? Math.min(8, availableParallelism()); + /** * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. */ diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts index 4146bec8bec1..4ad098a24763 100644 --- a/packages/angular/build/src/utils/environment-options_spec.ts +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -181,3 +181,67 @@ describe('environment options - maxTransformWorkers', () => { expect(maxTransformWorkers).toBe(expected); }); }); + +describe('environment options - maxInlinerWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxInlinerWorkers to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '4'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(4); + }); + + it('allows maxInlinerWorkers greater than 8 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '16'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(16); + }); + + it('supports maxInlinerWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(1); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxInlinerWorkers: zeroWorkers } = loadEnvironmentOptions(); + expect(zeroWorkers).toBe(Math.min(8, availableParallelism())); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxInlinerWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); +}); 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 df33f0766607..cd07fb821640 100644 --- a/packages/angular/build/src/utils/index-file/auto-csp.ts +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -36,15 +36,40 @@ function getScriptAttributeValue(tag: StartTag, attrName: string): string | unde return tag.attrs.find((attr) => attr.name === attrName)?.value; } +/** + * All MIME types associated with JavaScript according to the HTML specification: + * https://html.spec.whatwg.org/multipage/scripting.html#javascript-mime-type + */ +const JAVASCRIPT_MIME_TYPES = new Set([ + 'application/ecmascript', + 'application/javascript', + 'application/x-ecmascript', + 'application/x-javascript', + 'text/ecmascript', + 'text/javascript', + 'text/javascript1.0', + 'text/javascript1.1', + 'text/javascript1.2', + 'text/javascript1.3', + 'text/javascript1.4', + 'text/javascript1.5', + 'text/jscript', + 'text/livescript', + 'text/x-ecmascript', + 'text/x-javascript', +]); + /** * Checks whether a particular string is a MIME type associated with JavaScript, according to - * https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types#textjavascript + * https://html.spec.whatwg.org/multipage/scripting.html#javascript-mime-type * * @param mimeType a string that may be a MIME type * @returns whether the string is a MIME type that is associated with JavaScript */ -function isJavascriptMimeType(mimeType: string): boolean { - return mimeType.split(';')[0] === 'text/javascript'; +export function isJavascriptMimeType(mimeType: string): boolean { + const [essence] = mimeType.split(';', 1); + + return JAVASCRIPT_MIME_TYPES.has(essence.trim().toLowerCase()); } /** @@ -54,7 +79,11 @@ function isJavascriptMimeType(mimeType: string): boolean { * @returns whether to add the script tag to the dynamically loaded script tag */ function shouldDynamicallyLoadScriptTagBasedOnType(scriptType: string | undefined): boolean { - return !scriptType || scriptType === 'module' || isJavascriptMimeType(scriptType); + if (!scriptType) { + return true; + } + + return scriptType.trim().toLowerCase() === 'module' || isJavascriptMimeType(scriptType); } /** @@ -270,8 +299,10 @@ function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false) .map((s) => { // URI encoding means value can't escape string, JS, or HTML context. 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 + "'" : "''"; + // 'module', a JS MIME type, or an empty string. A JS MIME type may carry + // parameters after a ';', which isJavascriptMimeType() does not constrain, + // so encode this the same way as integrity and crossOrigin below. + const typeAttr = JSON.stringify(s.type ?? '').replaceAll('<', '\\u003c'); const asyncAttr = !!s.async; const deferAttr = !!s.defer; const integrityAttr = JSON.stringify(s.integrity ?? null).replaceAll('<', '\\u003c'); 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 29e9bff68074..037ab605253a 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 @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { autoCsp, hashTextContent } from './auto-csp'; +import { autoCsp, hashTextContent, isJavascriptMimeType } from './auto-csp'; // Utility function to grab the meta tag CSPs from the HTML response. const getCsps = (html: string) => { @@ -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(`const scripts = [['./main.js', '', false, false, null, null]];`); + 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* + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "application/javascript", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive type and parameters with whitespace', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Text/JavaScript ; charset=utf-8", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive module type', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Module", false, false, null, null]];`, + ); + }); + + it('should not rewrite non-JavaScript script tags', async () => { + const result = await autoCsp(` + + + + + + + `); + + // No dynamic loader script is emitted because application/json is not JavaScript. + expect(result).toContain(''); + expect(result).not.toContain('const scripts ='); + }); + + it('should encode a script type that carries MIME parameters', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // The type stays inside its string literal. + expect(result).toContain( + `const scripts = [['./main.js', "text/javascript;']];var x=1;var junk=[['a','b", false, false, null, null]];`, + ); + }); + + it('should encode a script type that contains a closing script tag', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Only the loader element is emitted. + expect(Array.from(result.matchAll(/