diff --git a/.agent/skills b/.agent/skills deleted file mode 120000 index 85ae949a9ab2..000000000000 --- a/.agent/skills +++ /dev/null @@ -1 +0,0 @@ -../.gemini/skills \ No newline at end of file diff --git a/.gemini/skills/adev-writing-guide/SKILL.md b/.agent/skills/adev-writing-guide/SKILL.md similarity index 97% rename from .gemini/skills/adev-writing-guide/SKILL.md rename to .agent/skills/adev-writing-guide/SKILL.md index 4855ec6b95de..250afcbfad1a 100644 --- a/.gemini/skills/adev-writing-guide/SKILL.md +++ b/.agent/skills/adev-writing-guide/SKILL.md @@ -1,6 +1,6 @@ --- name: adev-writing-guide -description: Comprehensive writing guide for Angular documentation (adev). Covers Google Technical Writing standards, Angular-specific markdown extensions, code blocks, and components. Use when authoring or reviewing content in adev/src/content. +description: Comprehensive writing guide for Angular documentation (adev). Covers Google Technical Writing standards, Angular-specific markdown extensions, code blocks, and components. You MUST use this skill any time you plan to create, edit, or review documentation files in `adev/` or `adev/src/content`. --- # Angular Documentation (adev) Writing Guide diff --git a/.agent/skills/pr_review/SKILL.md b/.agent/skills/pr_review/SKILL.md new file mode 100644 index 000000000000..f407ad4fe904 --- /dev/null +++ b/.agent/skills/pr_review/SKILL.md @@ -0,0 +1,134 @@ +--- +name: PR Review +description: Guidelines and tools for reviewing pull requests in the Angular repository. +--- + +# PR Review Guidelines + +When reviewing a pull request for the `angular` repository, follow these essential guidelines to ensure high-quality contributions: + +1. **Context & Ecosystem**: + - Keep in mind that this is the core Angular framework. Changes here can impact millions of developers. + - Be mindful of backwards compatibility. Breaking changes require strict approval processes and deprecation periods. + +2. **Key Focus Areas**: + - **Comprehensive Reviews**: You **MUST always** perform a deep, comprehensive review of the _entire_ pull request. If the user asks you to look into a specific issue, file, or area of concern, you must investigate that specific area _in addition to_ reviewing the rest of the PR's substantive changes. Do not terminate your review after addressing only the user's focal point. + - **Package-Specific Guidelines**: Check if there are specific guidelines for the package being modified in the `reference/` directory (e.g., `reference/router.md`). Always prioritize these rules for their respective packages. + - **Commit Messages**: Evaluate the quality of commit messages. They should explain the _why_ behind the change, not just the _what_. Someone should be able to look at the commit history years from now and clearly understand the context and reasoning for the change. + - **Code Cleanliness**: Ensure the code is readable, maintainable, and follows Angular's project standards. + - **Performance**: Look out for code that might negatively impact runtime performance or bundle size, particularly in hot paths like change detection or rendering. + - **Testing**: Ensure all new logic has comprehensive tests, including edge cases. **Do NOT run tests locally** as part of your review process. CI handles this automatically, and running tests locally is redundant and inefficient. + - **API Design**: Ensure new public APIs are well-designed, consistent with existing APIs, and properly documented. + - **Payload Size**: Pay attention to the impact of changes on the final client payload size. + +3. **Execution Workflow**: + Determine the appropriate review method. If the user explicitly asks for a `remote` or `local` review in their request, that takes precedence (e.g. "leave comments on the PR" implies `remote`). Otherwise, use the GitHub MCP or available scripts to determine if the review should be `local` or `remote`. + + **Common Review Practices (Applies to both Local and Remote)** + - **Preparation & Checklist**: + - First, create a task list (e.g., in `task.md`) that you can easily reference containing **all** the review requirements from the "Key Focus Areas" section (Commit Messages, Performance, Testing, etc.), along with any specific review notes or requests from the user. + - Before doing an in-depth review, expand this list into more detailed items of what you plan to explore and verify in the PR. + - As you conduct the review, check off items in this list, adding your assessment or findings underneath each item. + - At the end of your review, refer back to the checklist to ensure every single requirement was completely verified. + - **Fetch PR Metadata Safely**: When you need to read the PR description or context, do NOT use `gh pr view ` by itself, as its default GraphQL query may fail due to lack of `read:org` and `read:discussion` token scopes. Instead, use `read_url_content` on the PR URL or use `gh pr view --json title,body,state,author`. + - **Check Existing Comments First**: Before formulating feedback, use the GitHub MCP or available scripts to fetch existing comments on the PR. Review this feedback to avoid duplicate comments, and incorporate its insights into your own review process. + - **Constructive Feedback**: Provide clear, actionable, and polite feedback. Explain the _why_ behind your suggestions or edits. Do **NOT** leave inline comments purely to praise, agree with, or acknowledge a correct implementation detail, as this clutters the review. If you want to praise the PR, do so in the single general PR comment. + + **A. Local Code Review (If the PR is owned by the author requesting the review)** + - **Checkout**: Check out the PR branch locally (if it doesn't already exist, fetch it). If checking out the branch fails due to a worktree claim (e.g. "fatal: '' is already used by worktree at ''"), do the review in that directory. + - **Review & Edit**: Execute the review directly on the code. Instead of adding inline PR comments for suggestions, format the codebase or apply the edits directly to the files. + - **Feedback**: Summarize the review findings and the concrete changes you made in a message to the user, referencing the completed items from your checklist. + - **Do NOT Commit or Push**: Leave the changes uncommitted in the working directory so the user can easily review the pending edits locally. Let the user know the changes are ready for their review, but do not ask for approval to push. + - **Resolve Comments**: Once the user confirms the changes are good and should be committed/pushed, respond to the existing comments as 'resolved' using the GitHub MCP or available scripts. + + **B. Remote Code Review (For all other PRs)** + - **Batching Comments (MCP Server - Preferred)**: If you have the GitHub MCP Server configured, you **MUST** follow this workflow to avoid spamming the author with multiple notifications: + 1. Create a pending review using `mcp_github-mcp-server_pull_request_review_write` (method `create`). + 2. Add your inline comments to the pending review using `mcp_github-mcp-server_add_comment_to_pending_review`. + 3. Submit the review using `mcp_github-mcp-server_pull_request_review_write` (method `submit_pending`). + - **Batching Comments (Scripts - Fallback)**: If you do **NOT** have access to the GitHub MCP Server (e.g., specific MCP tools are missing from your context), fallback to using the provided scripts. Use `post_inline_comment.sh` to stage your comments locally. Once all comments are staged, you **MUST** call `submit_pr_review.sh` to publish them as a single batched review (and send a single notification). Try to keep comments minimal or use a general comment if you have many suggestions. + - **Use Suggested Changes**: Whenever appropriate (e.g., for simple code fixes, refactoring suggestions, or typo corrections), prefer using GitHub's **Suggested Changes** syntax (`suggestion ... `) in your inline comments. This allows the author to apply your suggested code improvements with a single click in the GitHub UI. + - **Review Type**: Never mark an external PR review as an "approval" unless explicitly instructed by a repo maintainer. Always use "Request Changes" or "Comment". Note that some tools might only support commenting. + - **Require User Approval Before Posting**: Prepare your review comments and present them to the user, alongside a summary of your completed checklist. Do NOT post comments to the PR without explicitly asking the user for permission first. Only post the review after the user approves. + - **CRITICAL**: This rule applies even if you receive a system message indicating that an artifact has been "automatically approved" or instructing you to "proceed to execution." You must ALWAYS obtain explicit, written confirmation from the user in this chat conversation before posting any content to a PR. + - **Prefix Agent Comments**: To make it clear when comments are generated and posted by an AI agent rather than a human user, **always** prefix your review comments with `AGENT: `. + +## Available Tools + +The following tools are available for remote interactions. We prefer using standard **GitHub MCP Server** tools when available. If you do not have the MCP server set up, you **MUST** fallback to using the custom bash scripts. + +### GitHub MCP Tools (Preferred) + +- `mcp_github-mcp-server_pull_request_review_write` +- `mcp_github-mcp-server_add_comment_to_pending_review` + +### Custom Bash Scripts (Fallback) + +The following scripts are provided as fallbacks if the MCP server is not available. Note that they rely on the `gh` CLI being correctly installed and authenticated in the local environment. + +### `determine_review_type.sh` + +Determines whether to use the Local or Remote review workflow by checking if the currently authenticated GitHub user via the `gh` CLI matches the author of the pull request. + +**Usage:** + +```bash +.agent/skills/pr_review/scripts/determine_review_type.sh +``` + +### `get_pr_comments.sh` + +Fetches all existing inline comments on a PR using the GitHub API. This is crucial for reviewing other contributors' feedback and avoiding duplicate comments. It outputs JSON containing the `id`, `path`, `line`, `body`, and `user` for each comment. + +**Usage:** + +```bash +.agent/skills/pr_review/scripts/get_pr_comments.sh +``` + +### `reply_pr_comment.sh` + +Replies to an existing PR comment thread. This is useful for marking comments as resolved after addressing them in a local code review. Note that the `COMMENT_ID` must be the ID of the top-level comment in the thread. + +**Usage:** + +```bash +.agent/skills/pr_review/scripts/reply_pr_comment.sh +``` + +### `post_inline_comment.sh` + +The GitHub CLI `gh pr review` command does not natively support adding inline comments to specific lines of code via its standard flags. This script wraps the GitHub API to stage comments locally. They will not be published until you call `submit_pr_review.sh`. + +**Usage:** + +```bash +.agent/skills/pr_review/scripts/post_inline_comment.sh +``` + +**Example:** + +```bash +.agent/skills/pr_review/scripts/post_inline_comment.sh 12345 "packages/core/src/render3/instructions/element.ts" 42 "AGENT: Consider the performance implications here." +``` + +### `submit_pr_review.sh` + +Submits all locally staged inline comments as a single batched review via the GitHub Pull Request Reviews API. + +**Usage:** + +```bash +.agent/skills/pr_review/scripts/submit_pr_review.sh [BODY] +``` + +**Options:** + +- `EVENT_TYPE`: Must be `COMMENT`, `APPROVE`, or `REQUEST_CHANGES`. Never use `APPROVE` for external PRs. +- `BODY`: (Optional) A general summary comment for the review. + +**Example:** + +```bash +.agent/skills/pr_review/scripts/submit_pr_review.sh 12345 COMMENT "AGENT: I have left a few inline suggestions for your consideration." +``` diff --git a/.agent/skills/pr_review/reference/router.md b/.agent/skills/pr_review/reference/router.md new file mode 100644 index 000000000000..2d0569af9247 --- /dev/null +++ b/.agent/skills/pr_review/reference/router.md @@ -0,0 +1,7 @@ +# Router PR Review Guidelines + +When reviewing pull requests that modify the Angular Router (`packages/router`), pay special attention to the following: + +- **Timing Sensitivity**: The router is extremely sensitive to timing changes. Any changes that alter the asynchronous timing of navigations, resolvers, or guards are almost always breaking changes and must be scrutinized carefully. +- **Testing Practices**: Tests should usually use the `RouterTestingHarness`. Many existing tests are older and do not use this harness. Do not blindly follow the shape of existing tests when writing or reviewing new ones; encourage the use of modern testing utilities. +- **Feature Justification**: Changes to router core code should be well-justified. Consider whether the change is proven to be a core developer ask, such as resolving a highly upvoted GitHub issue or addressing a critical bug. diff --git a/.agent/skills/pr_review/scripts/determine_review_type.sh b/.agent/skills/pr_review/scripts/determine_review_type.sh new file mode 100755 index 000000000000..d773b40f6e42 --- /dev/null +++ b/.agent/skills/pr_review/scripts/determine_review_type.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# determine_review_type.sh +# Determines if the PR should be reviewed locally or remotely based on author. + +if [ -z "$1" ]; then + echo "Usage: determine_review_type.sh " + exit 1 +fi + +PR_NUMBER=$1 + +# Get current authenticated user +CURRENT_USER=$(gh api user -q .login 2>/dev/null) +if [ $? -ne 0 ]; then + echo "Error: Could not determine current GitHub user. Are you logged in to gh?" + exit 1 +fi + +# Get PR author +PR_AUTHOR=$(gh pr view "$PR_NUMBER" --json author -q .author.login 2>/dev/null) +if [ $? -ne 0 ]; then + echo "Error: Could not retrieve PR information for $PR_NUMBER." + exit 1 +fi + +if [ "$CURRENT_USER" = "$PR_AUTHOR" ]; then + echo "local" +else + echo "remote" +fi diff --git a/.agent/skills/pr_review/scripts/get_pr_comments.sh b/.agent/skills/pr_review/scripts/get_pr_comments.sh new file mode 100755 index 000000000000..e6c3fbeece46 --- /dev/null +++ b/.agent/skills/pr_review/scripts/get_pr_comments.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# get_pr_comments.sh +# Fetches existing inline comments on a PR to avoid duplicate reviews. +# Usage: ./get_pr_comments.sh + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER="$1" + +# Ensure gh cli is installed +if ! command -v gh &> /dev/null; then + echo "Error: gh CLI could not be found. Please install and authenticate." + exit 1 +fi + +# Get the current repository (e.g., angular/angular) +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +# Fetch comments +gh api \ + --paginate \ + -H "Accept: application/vnd.github+json" \ + "/repos/${REPO}/pulls/${PR_NUMBER}/comments" \ + --jq '.[] | {id: .id, path: .path, line: .line, body: .body, user: .user.login}' diff --git a/.agent/skills/pr_review/scripts/post_inline_comment.sh b/.agent/skills/pr_review/scripts/post_inline_comment.sh new file mode 100755 index 000000000000..61956cb1f390 --- /dev/null +++ b/.agent/skills/pr_review/scripts/post_inline_comment.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# post_inline_comment.sh +# Adds an inline comment to a specific line in a PR via the GitHub API. +# Usage: ./post_inline_comment.sh + +if [ "$#" -lt 4 ]; then + echo "Usage: $0 " + exit 1 +fi + +PR_NUMBER="$1" +FILE_PATH="$2" +LINE="$3" +BODY="$4" + +# Ensure gh cli is installed +if ! command -v gh &> /dev/null; then + echo "Error: gh CLI could not be found. Please install and authenticate." + exit 1 +fi + +# Get the current repository (e.g., angular/angular) +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +echo "Staging inline comment for PR #${PR_NUMBER} on ${FILE_PATH}:${LINE}..." + +COMMENT_FILE="/tmp/angular_pr_${PR_NUMBER}_comments.json" +if [ ! -f "$COMMENT_FILE" ]; then + echo "[]" > "$COMMENT_FILE" +fi + +# Append the new comment to the JSON array +jq --arg path "${FILE_PATH}" --argjson line "${LINE}" --arg body "${BODY}" \ + '. += [{"path": $path, "line": $line, "body": $body}]' "$COMMENT_FILE" > "${COMMENT_FILE}.tmp" && mv "${COMMENT_FILE}.tmp" "$COMMENT_FILE" + +echo "Comment successfully staged locally. Remember to call submit_pr_review.sh when finished to publish all comments as a single review!" diff --git a/.agent/skills/pr_review/scripts/reply_pr_comment.sh b/.agent/skills/pr_review/scripts/reply_pr_comment.sh new file mode 100755 index 000000000000..bd4577c37aca --- /dev/null +++ b/.agent/skills/pr_review/scripts/reply_pr_comment.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# reply_pr_comment.sh +# Replies to an existing PR comment thread. Note: COMMENT_ID must be the ID of the top-level comment in the thread you are replying to. + +if [ "$#" -lt 3 ]; then + echo "Usage: reply_pr_comment.sh " + exit 1 +fi + +PR_NUMBER="$1" +COMMENT_ID="$2" +BODY="$3" + +# Ensure gh cli is installed +if ! command -v gh &> /dev/null; then + echo "Error: gh CLI could not be found. Please install and authenticate." + exit 1 +fi + +# Get the current repository (e.g., angular/angular) +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +# Reply to the thread using the provided comment ID +gh api \ + --silent \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + "/repos/${REPO}/pulls/${PR_NUMBER}/comments/${COMMENT_ID}/replies" \ + -f body="$BODY" diff --git a/.agent/skills/pr_review/scripts/submit_pr_review.sh b/.agent/skills/pr_review/scripts/submit_pr_review.sh new file mode 100755 index 000000000000..9dc28dad757c --- /dev/null +++ b/.agent/skills/pr_review/scripts/submit_pr_review.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# submit_pr_review.sh +# Submits a batched PR review using comments previously staged by post_inline_comment.sh +# Usage: ./submit_pr_review.sh [BODY] +# EVENT_TYPE must be COMMENT, APPROVE, or REQUEST_CHANGES + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [BODY]" + echo "EVENT_TYPE must be COMMENT, APPROVE, or REQUEST_CHANGES" + exit 1 +fi + +PR_NUMBER="$1" +EVENT="$2" +BODY="${3:-}" +COMMENT_FILE="/tmp/angular_pr_${PR_NUMBER}_comments.json" + +if ! command -v gh &> /dev/null; then + echo "Error: gh CLI could not be found. Please install and authenticate." + exit 1 +fi + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +# Check if there are staged comments +COMMENTS="[]" +if [ -f "$COMMENT_FILE" ]; then + COMMENTS=$(cat "$COMMENT_FILE") +fi + +echo "Submitting review for PR #${PR_NUMBER}..." + +# Create the payload +PAYLOAD_FILE="/tmp/angular_pr_${PR_NUMBER}_payload.json" +jq -n --arg event "$EVENT" --arg body "$BODY" --argjson comments "$COMMENTS" \ + '{event: $event, body: $body, comments: $comments}' > "$PAYLOAD_FILE" + +# Post the review using the GitHub Pull Request Reviews API +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/pulls/${PR_NUMBER}/reviews" \ + --input "$PAYLOAD_FILE" + +echo "Review submitted successfully!" +rm -f "$COMMENT_FILE" +rm -f "$PAYLOAD_FILE" diff --git a/.gemini/skills/reference-compiler-cli/SKILL.md b/.agent/skills/reference-compiler-cli/SKILL.md similarity index 100% rename from .gemini/skills/reference-compiler-cli/SKILL.md rename to .agent/skills/reference-compiler-cli/SKILL.md diff --git a/.gemini/skills/reference-core/SKILL.md b/.agent/skills/reference-core/SKILL.md similarity index 100% rename from .gemini/skills/reference-core/SKILL.md rename to .agent/skills/reference-core/SKILL.md diff --git a/.gemini/skills/reference-signal-forms/SKILL.md b/.agent/skills/reference-signal-forms/SKILL.md similarity index 100% rename from .gemini/skills/reference-signal-forms/SKILL.md rename to .agent/skills/reference-signal-forms/SKILL.md diff --git a/.gemini/skills/reference-signal-forms/references/integration.md b/.agent/skills/reference-signal-forms/references/integration.md similarity index 100% rename from .gemini/skills/reference-signal-forms/references/integration.md rename to .agent/skills/reference-signal-forms/references/integration.md diff --git a/.agent/workflows/fix-flaky-tests.md b/.agent/workflows/fix-flaky-tests.md new file mode 100644 index 000000000000..a747b6e9466d --- /dev/null +++ b/.agent/workflows/fix-flaky-tests.md @@ -0,0 +1,56 @@ +--- +description: Find and fix flaky tests in the repository +--- + +Investigate flaky tests in the repo and propose fixes to improve stability. +High-level process: + +1. Run tests in the repo to look for flakes. + - Consider using Bazel's `--runs_per_test` flag to easily find + flakes. + - Be cognizant of not exhausting all the resources on the current + machine, run a subset of tests at a time such as + `bazel test //packages/core/...`. +2. Once you find some flakes, focus on one at a time. +3. Create a new branch named `flakes/${relevantNameFromTest}`. +4. Reproduce the flake to the best of your ability. + - Consider using `--test_env JASMINE_RANDOM_SEED=1234` to + replicate the broken test ordering. +5. Debug the test to understand the failure mode. + - Consider temporarily disabling / skipping other tests with `xit` + and `fit` to narrow down where the flake might be coming from if + multiple tests are influencing each other. + - Consider temporarily ignoring Firefox tests with + `--test_tag_filters -firefox` if the flake does not appear to be + browser specific. + - Consider using `--test_sharding_strategy disabled` to run the + test in a single shard. + - Try to understand why the test was _flaky_, not just why it + _failed_. Understanding the inconsistency is important to + finding the correct fix. +6. Attempt a fix and validate with `--runs_per_test`. + - Iterate on the fix until you have something which appears to + work. + - If you find yourself stuck and not making meaningful progress, + note down what you've learned/where you're struggling, commit + what you have, look for another flake to fix, and continue. At + the end, surface to the user what you failed to fix. + - Don't try to make significant changes to Angular's runtime + behavior, focus just on making the test pass/fail consistently. +7. Commit the change with relevant details in the commit message and + move on to the next test. + - Be sure to include your theory of why the test was flaky and + how this fix eliminates or reduces that flakiness. +8. Iterate as many times as the user requests you to (default 5 + branches if not otherwise specified). +9. Once you can't find any flaky tests or have iterated as many times + as requested, stop and inform the user what you found and fixed. + +Additional notes: + +- Multiple fixes including the same/related files can go in the same + commit or multiple commits on the same branch. +- Distinct test fixes should go in different branches, make a new one + for each investigation. +- You may push these branches to `origin`, but do not create PRs for + them. diff --git a/.bazelversion b/.bazelversion index f9c71a52e2fd..acd405b1d62e 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -8.5.1 +8.6.0 diff --git a/.gemini/config.yaml b/.gemini/config.yaml index 28eab0db4d88..c37f5ba91c21 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -7,4 +7,5 @@ code_review: help: false summary: false code_review: false -ignore_patterns: [] +ignore_patterns: + - pnpm-lock.yaml diff --git a/.github/actions/deploy-docs-site/main.js b/.github/actions/deploy-docs-site/main.js index 99ebd156a9df..8f0747b7666c 100644 --- a/.github/actions/deploy-docs-site/main.js +++ b/.github/actions/deploy-docs-site/main.js @@ -10271,7 +10271,7 @@ function isKeyOperator(operator) { function getValues(context3, operator, key, modifier) { var value = context3[key], result = []; if (isDefined(value) && value !== "") { - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { value = value.toString(); if (modifier && modifier !== "*") { value = value.substring(0, parseInt(modifier, 10)); @@ -10452,6 +10452,125 @@ var endpoint = withDefaults(null, DEFAULTS); // var import_fast_content_type_parse = __toESM(require_fast_content_type_parse()); +// +var intRegex = /^-?\d+$/; +var noiseValue = /^-?\d+n+$/; +var originalStringify = JSON.stringify; +var originalParse = JSON.parse; +var customFormat = /^-?\d+n$/; +var bigIntsStringify = /([\[:])?"(-?\d+)n"($|([\\n]|\s)*(\s|[\\n])*[,\}\]])/g; +var noiseStringify = /([\[:])?("-?\d+n+)n("$|"([\\n]|\s)*(\s|[\\n])*[,\}\]])/g; +var JSONStringify = (value, replacer, space) => { + if ("rawJSON" in JSON) { + return originalStringify( + value, + (key, value2) => { + if (typeof value2 === "bigint") + return JSON.rawJSON(value2.toString()); + if (typeof replacer === "function") + return replacer(key, value2); + if (Array.isArray(replacer) && replacer.includes(key)) + return value2; + return value2; + }, + space + ); + } + if (!value) + return originalStringify(value, replacer, space); + const convertedToCustomJSON = originalStringify( + value, + (key, value2) => { + const isNoise = typeof value2 === "string" && noiseValue.test(value2); + if (isNoise) + return value2.toString() + "n"; + if (typeof value2 === "bigint") + return value2.toString() + "n"; + if (typeof replacer === "function") + return replacer(key, value2); + if (Array.isArray(replacer) && replacer.includes(key)) + return value2; + return value2; + }, + space + ); + const processedJSON = convertedToCustomJSON.replace( + bigIntsStringify, + "$1$2$3" + ); + const denoisedJSON = processedJSON.replace(noiseStringify, "$1$2$3"); + return denoisedJSON; +}; +var featureCache = /* @__PURE__ */ new Map(); +var isContextSourceSupported = () => { + const parseFingerprint = JSON.parse.toString(); + if (featureCache.has(parseFingerprint)) { + return featureCache.get(parseFingerprint); + } + try { + const result = JSON.parse( + "1", + (_, __, context3) => !!context3?.source && context3.source === "1" + ); + featureCache.set(parseFingerprint, result); + return result; + } catch { + featureCache.set(parseFingerprint, false); + return false; + } +}; +var convertMarkedBigIntsReviver = (key, value, context3, userReviver) => { + const isCustomFormatBigInt = typeof value === "string" && customFormat.test(value); + if (isCustomFormatBigInt) + return BigInt(value.slice(0, -1)); + const isNoiseValue = typeof value === "string" && noiseValue.test(value); + if (isNoiseValue) + return value.slice(0, -1); + if (typeof userReviver !== "function") + return value; + return userReviver(key, value, context3); +}; +var JSONParseV2 = (text, reviver) => { + return JSON.parse(text, (key, value, context3) => { + const isBigNumber = typeof value === "number" && (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER); + const isInt = context3 && intRegex.test(context3.source); + const isBigInt = isBigNumber && isInt; + if (isBigInt) + return BigInt(context3.source); + if (typeof reviver !== "function") + return value; + return reviver(key, value, context3); + }); +}; +var MAX_INT = Number.MAX_SAFE_INTEGER.toString(); +var MAX_DIGITS = MAX_INT.length; +var stringsOrLargeNumbers = /"(?:\\.|[^"])*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/g; +var noiseValueWithQuotes = /^"-?\d+n+"$/; +var JSONParse = (text, reviver) => { + if (!text) + return originalParse(text, reviver); + if (isContextSourceSupported()) + return JSONParseV2(text, reviver); + const serializedData = text.replace( + stringsOrLargeNumbers, + (text2, digits, fractional, exponential) => { + const isString = text2[0] === '"'; + const isNoise = isString && noiseValueWithQuotes.test(text2); + if (isNoise) + return text2.substring(0, text2.length - 1) + 'n"'; + const isFractionalOrExponential = fractional || exponential; + const isLessThanMaxSafeInt = digits && (digits.length < MAX_DIGITS || digits.length === MAX_DIGITS && digits <= MAX_INT); + if (isString || isFractionalOrExponential || isLessThanMaxSafeInt) + return text2; + return '"' + text2 + 'n"'; + } + ); + return originalParse( + serializedData, + (key, value, context3) => convertMarkedBigIntsReviver(key, value, context3, reviver) + ); +}; + // var RequestError = class extends Error { name; @@ -10492,7 +10611,7 @@ var RequestError = class extends Error { }; // -var VERSION2 = "10.0.7"; +var VERSION2 = "10.0.8"; var defaults_default = { headers: { "user-agent": `octokit-request.js/${VERSION2} ${getUserAgent()}` @@ -10519,7 +10638,7 @@ async function fetchWrapper(requestOptions) { } const log = requestOptions.request?.log || console; const parseSuccessResponseBody = requestOptions.request?.parseSuccessResponseBody !== false; - const body = isPlainObject2(requestOptions.body) || Array.isArray(requestOptions.body) ? JSON.stringify(requestOptions.body) : requestOptions.body; + const body = isPlainObject2(requestOptions.body) || Array.isArray(requestOptions.body) ? JSONStringify(requestOptions.body) : requestOptions.body; const requestHeaders = Object.fromEntries( Object.entries(requestOptions.headers).map(([name, value]) => [ name, @@ -10618,7 +10737,7 @@ async function getResponseData(response) { let text = ""; try { text = await response.text(); - return JSON.parse(text); + return JSONParse(text); } catch (err) { return text; } @@ -14135,17 +14254,81 @@ function stripAnsi(string) { if (typeof string !== "string") { throw new TypeError(`Expected a \`string\`, got \`${typeof string}\``); } + if (!string.includes("\x1B") && !string.includes("\x9B")) { + return string; + } return string.replace(regex, ""); } -function isAmbiguous(x) { - return x === 161 || x === 164 || x === 167 || x === 168 || x === 170 || x === 173 || x === 174 || x >= 176 && x <= 180 || x >= 182 && x <= 186 || x >= 188 && x <= 191 || x === 198 || x === 208 || x === 215 || x === 216 || x >= 222 && x <= 225 || x === 230 || x >= 232 && x <= 234 || x === 236 || x === 237 || x === 240 || x === 242 || x === 243 || x >= 247 && x <= 250 || x === 252 || x === 254 || x === 257 || x === 273 || x === 275 || x === 283 || x === 294 || x === 295 || x === 299 || x >= 305 && x <= 307 || x === 312 || x >= 319 && x <= 322 || x === 324 || x >= 328 && x <= 331 || x === 333 || x === 338 || x === 339 || x === 358 || x === 359 || x === 363 || x === 462 || x === 464 || x === 466 || x === 468 || x === 470 || x === 472 || x === 474 || x === 476 || x === 593 || x === 609 || x === 708 || x === 711 || x >= 713 && x <= 715 || x === 717 || x === 720 || x >= 728 && x <= 731 || x === 733 || x === 735 || x >= 768 && x <= 879 || x >= 913 && x <= 929 || x >= 931 && x <= 937 || x >= 945 && x <= 961 || x >= 963 && x <= 969 || x === 1025 || x >= 1040 && x <= 1103 || x === 1105 || x === 8208 || x >= 8211 && x <= 8214 || x === 8216 || x === 8217 || x === 8220 || x === 8221 || x >= 8224 && x <= 8226 || x >= 8228 && x <= 8231 || x === 8240 || x === 8242 || x === 8243 || x === 8245 || x === 8251 || x === 8254 || x === 8308 || x === 8319 || x >= 8321 && x <= 8324 || x === 8364 || x === 8451 || x === 8453 || x === 8457 || x === 8467 || x === 8470 || x === 8481 || x === 8482 || x === 8486 || x === 8491 || x === 8531 || x === 8532 || x >= 8539 && x <= 8542 || x >= 8544 && x <= 8555 || x >= 8560 && x <= 8569 || x === 8585 || x >= 8592 && x <= 8601 || x === 8632 || x === 8633 || x === 8658 || x === 8660 || x === 8679 || x === 8704 || x === 8706 || x === 8707 || x === 8711 || x === 8712 || x === 8715 || x === 8719 || x === 8721 || x === 8725 || x === 8730 || x >= 8733 && x <= 8736 || x === 8739 || x === 8741 || x >= 8743 && x <= 8748 || x === 8750 || x >= 8756 && x <= 8759 || x === 8764 || x === 8765 || x === 8776 || x === 8780 || x === 8786 || x === 8800 || x === 8801 || x >= 8804 && x <= 8807 || x === 8810 || x === 8811 || x === 8814 || x === 8815 || x === 8834 || x === 8835 || x === 8838 || x === 8839 || x === 8853 || x === 8857 || x === 8869 || x === 8895 || x === 8978 || x >= 9312 && x <= 9449 || x >= 9451 && x <= 9547 || x >= 9552 && x <= 9587 || x >= 9600 && x <= 9615 || x >= 9618 && x <= 9621 || x === 9632 || x === 9633 || x >= 9635 && x <= 9641 || x === 9650 || x === 9651 || x === 9654 || x === 9655 || x === 9660 || x === 9661 || x === 9664 || x === 9665 || x >= 9670 && x <= 9672 || x === 9675 || x >= 9678 && x <= 9681 || x >= 9698 && x <= 9701 || x === 9711 || x === 9733 || x === 9734 || x === 9737 || x === 9742 || x === 9743 || x === 9756 || x === 9758 || x === 9792 || x === 9794 || x === 9824 || x === 9825 || x >= 9827 && x <= 9829 || x >= 9831 && x <= 9834 || x === 9836 || x === 9837 || x === 9839 || x === 9886 || x === 9887 || x === 9919 || x >= 9926 && x <= 9933 || x >= 9935 && x <= 9939 || x >= 9941 && x <= 9953 || x === 9955 || x === 9960 || x === 9961 || x >= 9963 && x <= 9969 || x === 9972 || x >= 9974 && x <= 9977 || x === 9979 || x === 9980 || x === 9982 || x === 9983 || x === 10045 || x >= 10102 && x <= 10111 || x >= 11094 && x <= 11097 || x >= 12872 && x <= 12879 || x >= 57344 && x <= 63743 || x >= 65024 && x <= 65039 || x === 65533 || x >= 127232 && x <= 127242 || x >= 127248 && x <= 127277 || x >= 127280 && x <= 127337 || x >= 127344 && x <= 127373 || x === 127375 || x === 127376 || x >= 127387 && x <= 127404 || x >= 917760 && x <= 917999 || x >= 983040 && x <= 1048573 || x >= 1048576 && x <= 1114109; -} -function isFullWidth(x) { - return x === 12288 || x >= 65281 && x <= 65376 || x >= 65504 && x <= 65510; -} -function isWide(x) { - return x >= 4352 && x <= 4447 || x === 8986 || x === 8987 || x === 9001 || x === 9002 || x >= 9193 && x <= 9196 || x === 9200 || x === 9203 || x === 9725 || x === 9726 || x === 9748 || x === 9749 || x >= 9776 && x <= 9783 || x >= 9800 && x <= 9811 || x === 9855 || x >= 9866 && x <= 9871 || x === 9875 || x === 9889 || x === 9898 || x === 9899 || x === 9917 || x === 9918 || x === 9924 || x === 9925 || x === 9934 || x === 9940 || x === 9962 || x === 9970 || x === 9971 || x === 9973 || x === 9978 || x === 9981 || x === 9989 || x === 9994 || x === 9995 || x === 10024 || x === 10060 || x === 10062 || x >= 10067 && x <= 10069 || x === 10071 || x >= 10133 && x <= 10135 || x === 10160 || x === 10175 || x === 11035 || x === 11036 || x === 11088 || x === 11093 || x >= 11904 && x <= 11929 || x >= 11931 && x <= 12019 || x >= 12032 && x <= 12245 || x >= 12272 && x <= 12287 || x >= 12289 && x <= 12350 || x >= 12353 && x <= 12438 || x >= 12441 && x <= 12543 || x >= 12549 && x <= 12591 || x >= 12593 && x <= 12686 || x >= 12688 && x <= 12773 || x >= 12783 && x <= 12830 || x >= 12832 && x <= 12871 || x >= 12880 && x <= 42124 || x >= 42128 && x <= 42182 || x >= 43360 && x <= 43388 || x >= 44032 && x <= 55203 || x >= 63744 && x <= 64255 || x >= 65040 && x <= 65049 || x >= 65072 && x <= 65106 || x >= 65108 && x <= 65126 || x >= 65128 && x <= 65131 || x >= 94176 && x <= 94180 || x >= 94192 && x <= 94198 || x >= 94208 && x <= 101589 || x >= 101631 && x <= 101662 || x >= 101760 && x <= 101874 || x >= 110576 && x <= 110579 || x >= 110581 && x <= 110587 || x === 110589 || x === 110590 || x >= 110592 && x <= 110882 || x === 110898 || x >= 110928 && x <= 110930 || x === 110933 || x >= 110948 && x <= 110951 || x >= 110960 && x <= 111355 || x >= 119552 && x <= 119638 || x >= 119648 && x <= 119670 || x === 126980 || x === 127183 || x === 127374 || x >= 127377 && x <= 127386 || x >= 127488 && x <= 127490 || x >= 127504 && x <= 127547 || x >= 127552 && x <= 127560 || x === 127568 || x === 127569 || x >= 127584 && x <= 127589 || x >= 127744 && x <= 127776 || x >= 127789 && x <= 127797 || x >= 127799 && x <= 127868 || x >= 127870 && x <= 127891 || x >= 127904 && x <= 127946 || x >= 127951 && x <= 127955 || x >= 127968 && x <= 127984 || x === 127988 || x >= 127992 && x <= 128062 || x === 128064 || x >= 128066 && x <= 128252 || x >= 128255 && x <= 128317 || x >= 128331 && x <= 128334 || x >= 128336 && x <= 128359 || x === 128378 || x === 128405 || x === 128406 || x === 128420 || x >= 128507 && x <= 128591 || x >= 128640 && x <= 128709 || x === 128716 || x >= 128720 && x <= 128722 || x >= 128725 && x <= 128728 || x >= 128732 && x <= 128735 || x === 128747 || x === 128748 || x >= 128756 && x <= 128764 || x >= 128992 && x <= 129003 || x === 129008 || x >= 129292 && x <= 129338 || x >= 129340 && x <= 129349 || x >= 129351 && x <= 129535 || x >= 129648 && x <= 129660 || x >= 129664 && x <= 129674 || x >= 129678 && x <= 129734 || x === 129736 || x >= 129741 && x <= 129756 || x >= 129759 && x <= 129770 || x >= 129775 && x <= 129784 || x >= 131072 && x <= 196605 || x >= 196608 && x <= 262141; +var ambiguousRanges = [161, 161, 164, 164, 167, 168, 170, 170, 173, 174, 176, 180, 182, 186, 188, 191, 198, 198, 208, 208, 215, 216, 222, 225, 230, 230, 232, 234, 236, 237, 240, 240, 242, 243, 247, 250, 252, 252, 254, 254, 257, 257, 273, 273, 275, 275, 283, 283, 294, 295, 299, 299, 305, 307, 312, 312, 319, 322, 324, 324, 328, 331, 333, 333, 338, 339, 358, 359, 363, 363, 462, 462, 464, 464, 466, 466, 468, 468, 470, 470, 472, 472, 474, 474, 476, 476, 593, 593, 609, 609, 708, 708, 711, 711, 713, 715, 717, 717, 720, 720, 728, 731, 733, 733, 735, 735, 768, 879, 913, 929, 931, 937, 945, 961, 963, 969, 1025, 1025, 1040, 1103, 1105, 1105, 8208, 8208, 8211, 8214, 8216, 8217, 8220, 8221, 8224, 8226, 8228, 8231, 8240, 8240, 8242, 8243, 8245, 8245, 8251, 8251, 8254, 8254, 8308, 8308, 8319, 8319, 8321, 8324, 8364, 8364, 8451, 8451, 8453, 8453, 8457, 8457, 8467, 8467, 8470, 8470, 8481, 8482, 8486, 8486, 8491, 8491, 8531, 8532, 8539, 8542, 8544, 8555, 8560, 8569, 8585, 8585, 8592, 8601, 8632, 8633, 8658, 8658, 8660, 8660, 8679, 8679, 8704, 8704, 8706, 8707, 8711, 8712, 8715, 8715, 8719, 8719, 8721, 8721, 8725, 8725, 8730, 8730, 8733, 8736, 8739, 8739, 8741, 8741, 8743, 8748, 8750, 8750, 8756, 8759, 8764, 8765, 8776, 8776, 8780, 8780, 8786, 8786, 8800, 8801, 8804, 8807, 8810, 8811, 8814, 8815, 8834, 8835, 8838, 8839, 8853, 8853, 8857, 8857, 8869, 8869, 8895, 8895, 8978, 8978, 9312, 9449, 9451, 9547, 9552, 9587, 9600, 9615, 9618, 9621, 9632, 9633, 9635, 9641, 9650, 9651, 9654, 9655, 9660, 9661, 9664, 9665, 9670, 9672, 9675, 9675, 9678, 9681, 9698, 9701, 9711, 9711, 9733, 9734, 9737, 9737, 9742, 9743, 9756, 9756, 9758, 9758, 9792, 9792, 9794, 9794, 9824, 9825, 9827, 9829, 9831, 9834, 9836, 9837, 9839, 9839, 9886, 9887, 9919, 9919, 9926, 9933, 9935, 9939, 9941, 9953, 9955, 9955, 9960, 9961, 9963, 9969, 9972, 9972, 9974, 9977, 9979, 9980, 9982, 9983, 10045, 10045, 10102, 10111, 11094, 11097, 12872, 12879, 57344, 63743, 65024, 65039, 65533, 65533, 127232, 127242, 127248, 127277, 127280, 127337, 127344, 127373, 127375, 127376, 127387, 127404, 917760, 917999, 983040, 1048573, 1048576, 1114109]; +var fullwidthRanges = [12288, 12288, 65281, 65376, 65504, 65510]; +var halfwidthRanges = [8361, 8361, 65377, 65470, 65474, 65479, 65482, 65487, 65490, 65495, 65498, 65500, 65512, 65518]; +var narrowRanges = [32, 126, 162, 163, 165, 166, 172, 172, 175, 175, 10214, 10221, 10629, 10630]; +var wideRanges = [4352, 4447, 8986, 8987, 9001, 9002, 9193, 9196, 9200, 9200, 9203, 9203, 9725, 9726, 9748, 9749, 9776, 9783, 9800, 9811, 9855, 9855, 9866, 9871, 9875, 9875, 9889, 9889, 9898, 9899, 9917, 9918, 9924, 9925, 9934, 9934, 9940, 9940, 9962, 9962, 9970, 9971, 9973, 9973, 9978, 9978, 9981, 9981, 9989, 9989, 9994, 9995, 10024, 10024, 10060, 10060, 10062, 10062, 10067, 10069, 10071, 10071, 10133, 10135, 10160, 10160, 10175, 10175, 11035, 11036, 11088, 11088, 11093, 11093, 11904, 11929, 11931, 12019, 12032, 12245, 12272, 12287, 12289, 12350, 12353, 12438, 12441, 12543, 12549, 12591, 12593, 12686, 12688, 12773, 12783, 12830, 12832, 12871, 12880, 42124, 42128, 42182, 43360, 43388, 44032, 55203, 63744, 64255, 65040, 65049, 65072, 65106, 65108, 65126, 65128, 65131, 94176, 94180, 94192, 94198, 94208, 101589, 101631, 101662, 101760, 101874, 110576, 110579, 110581, 110587, 110589, 110590, 110592, 110882, 110898, 110898, 110928, 110930, 110933, 110933, 110948, 110951, 110960, 111355, 119552, 119638, 119648, 119670, 126980, 126980, 127183, 127183, 127374, 127374, 127377, 127386, 127488, 127490, 127504, 127547, 127552, 127560, 127568, 127569, 127584, 127589, 127744, 127776, 127789, 127797, 127799, 127868, 127870, 127891, 127904, 127946, 127951, 127955, 127968, 127984, 127988, 127988, 127992, 128062, 128064, 128064, 128066, 128252, 128255, 128317, 128331, 128334, 128336, 128359, 128378, 128378, 128405, 128406, 128420, 128420, 128507, 128591, 128640, 128709, 128716, 128716, 128720, 128722, 128725, 128728, 128732, 128735, 128747, 128748, 128756, 128764, 128992, 129003, 129008, 129008, 129292, 129338, 129340, 129349, 129351, 129535, 129648, 129660, 129664, 129674, 129678, 129734, 129736, 129736, 129741, 129756, 129759, 129770, 129775, 129784, 131072, 196605, 196608, 262141]; +var isInRange = (ranges, codePoint) => { + let low = 0; + let high = Math.floor(ranges.length / 2) - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const i = mid * 2; + if (codePoint < ranges[i]) { + high = mid - 1; + } else if (codePoint > ranges[i + 1]) { + low = mid + 1; + } else { + return true; + } + } + return false; +}; +var minimumAmbiguousCodePoint = ambiguousRanges[0]; +var maximumAmbiguousCodePoint = ambiguousRanges.at(-1); +var minimumFullWidthCodePoint = fullwidthRanges[0]; +var maximumFullWidthCodePoint = fullwidthRanges.at(-1); +var minimumHalfWidthCodePoint = halfwidthRanges[0]; +var maximumHalfWidthCodePoint = halfwidthRanges.at(-1); +var minimumNarrowCodePoint = narrowRanges[0]; +var maximumNarrowCodePoint = narrowRanges.at(-1); +var minimumWideCodePoint = wideRanges[0]; +var maximumWideCodePoint = wideRanges.at(-1); +var commonCjkCodePoint = 19968; +var [wideFastPathStart, wideFastPathEnd] = findWideFastPathRange(wideRanges); +function findWideFastPathRange(ranges) { + let fastPathStart = ranges[0]; + let fastPathEnd = ranges[1]; + for (let index = 0; index < ranges.length; index += 2) { + const start = ranges[index]; + const end = ranges[index + 1]; + if (commonCjkCodePoint >= start && commonCjkCodePoint <= end) { + return [start, end]; + } + if (end - start > fastPathEnd - fastPathStart) { + fastPathStart = start; + fastPathEnd = end; + } + } + return [fastPathStart, fastPathEnd]; } +var isAmbiguous = (codePoint) => { + if (codePoint < minimumAmbiguousCodePoint || codePoint > maximumAmbiguousCodePoint) { + return false; + } + return isInRange(ambiguousRanges, codePoint); +}; +var isFullWidth = (codePoint) => { + if (codePoint < minimumFullWidthCodePoint || codePoint > maximumFullWidthCodePoint) { + return false; + } + return isInRange(fullwidthRanges, codePoint); +}; +var isWide = (codePoint) => { + if (codePoint >= wideFastPathStart && codePoint <= wideFastPathEnd) { + return true; + } + if (codePoint < minimumWideCodePoint || codePoint > maximumWideCodePoint) { + return false; + } + return isInRange(wideRanges, codePoint); +}; function validate(codePoint) { if (!Number.isSafeInteger(codePoint)) { throw new TypeError(`Expected a code point, got \`${typeof codePoint}\`.`); @@ -19065,7 +19248,7 @@ var ChildProcess = class { return new Promise((resolve5, reject) => { const commandText = `${command2} ${args.join(" ")}`; Log.debug(`Executing command: ${commandText}`); - const childProcess = _spawn(command2, args, { ...options, shell: true, stdio: "inherit" }); + const childProcess = _spawn(command2, args, { ...options, stdio: "inherit" }); childProcess.on("close", (status) => status === 0 ? resolve5() : reject(status)); }); } @@ -19073,7 +19256,7 @@ var ChildProcess = class { const commandText = `${command2} ${args.join(" ")}`; const env22 = getEnvironmentForNonInteractiveCommand(options.env); Log.debug(`Executing command: ${commandText}`); - const { status: exitCode, signal, stdout, stderr } = _spawnSync(command2, args, { ...options, env: env22, encoding: "utf8", shell: true, stdio: "pipe" }); + const { status: exitCode, signal, stdout, stderr } = _spawnSync(command2, args, { ...options, env: env22, encoding: "utf8", stdio: "pipe" }); const status = statusFromExitCodeAndSignal(exitCode, signal); if (status === 0 || options.suppressErrorOnFailingExitCode) { return { status, stdout, stderr }; @@ -19083,7 +19266,7 @@ var ChildProcess = class { static spawn(command2, args, options = {}) { const commandText = `${command2} ${args.join(" ")}`; const env22 = getEnvironmentForNonInteractiveCommand(options.env); - return processAsyncCmd(commandText, options, _spawn(command2, args, { ...options, env: env22, shell: true, stdio: "pipe" })); + return processAsyncCmd(commandText, options, _spawn(command2, args, { ...options, env: env22, stdio: "pipe" })); } static exec(command2, options = {}) { const env22 = getEnvironmentForNonInteractiveCommand(options.env); @@ -19138,7 +19321,7 @@ ${logOutput}`); }); } function determineRepoBaseDirFromCwd() { - const { stdout, stderr, status } = ChildProcess.spawnSync("git", ["rev-parse --show-toplevel"]); + const { stdout, stderr, status } = ChildProcess.spawnSync("git", ["rev-parse", "--show-toplevel"]); if (status !== 0) { throw Error(`Unable to find the path to the base directory of the repository. Was the command run from inside of the repo? @@ -22795,6 +22978,7 @@ var require_stringify = __commonJS2({ nullStr: "null", simpleKeys: false, singleQuote: null, + trailingComma: false, trueStr: "true", verifyAliasOrder: true }, doc.schema.toStringOptions, options); @@ -23300,12 +23484,19 @@ ${indent}${line}` : "\n"; if (comment) reqNewline = true; let str = stringify.stringify(item, itemCtx, () => comment = null); - if (i < items.length - 1) + reqNewline || (reqNewline = lines.length > linesAtValue || str.includes("\n")); + if (i < items.length - 1) { str += ","; + } else if (ctx.options.trailingComma) { + if (ctx.options.lineWidth > 0) { + reqNewline || (reqNewline = lines.reduce((sum, line) => sum + line.length + 2, 2) + (str.length + 2) > ctx.options.lineWidth); + } + if (reqNewline) { + str += ","; + } + } if (comment) str += stringifyComment.lineComment(str, itemIndent, commentString(comment)); - if (!reqNewline && (lines.length > linesAtValue || str.includes("\n"))) - reqNewline = true; lines.push(str); linesAtValue = lines.length; } @@ -26235,17 +26426,22 @@ var require_compose_node = __commonJS2({ case "block-map": case "block-seq": case "flow-collection": - node = composeCollection.composeCollection(CN, ctx, token, props, onError); - if (anchor) - node.anchor = anchor.source.substring(1); + try { + node = composeCollection.composeCollection(CN, ctx, token, props, onError); + if (anchor) + node.anchor = anchor.source.substring(1); + } catch (error2) { + const message = error2 instanceof Error ? error2.message : String(error2); + onError(token, "RESOURCE_EXHAUSTION", message); + } break; default: { const message = token.type === "error" ? token.message : `Unsupported token (type: ${token.type})`; onError(token, "UNEXPECTED_TOKEN", message); - node = composeEmptyNode(ctx, token.offset, void 0, null, props, onError); isSrcToken = false; } } + node ?? (node = composeEmptyNode(ctx, token.offset, void 0, null, props, onError)); if (anchor && node.anchor === "") onError(anchor, "BAD_ALIAS", "Anchor cannot be an empty string"); if (atKey && ctx.options.stringKeys && (!identity.isScalar(node) || typeof node.value !== "string" || node.tag && node.tag !== "tag:yaml.org,2002:str")) { @@ -28804,7 +29000,7 @@ function isKeyOperator2(operator) { function getValues2(context3, operator, key, modifier) { var value = context3[key], result = []; if (isDefined2(value) && value !== "") { - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { value = value.toString(); if (modifier && modifier !== "*") { value = value.substring(0, parseInt(modifier, 10)); @@ -28982,6 +29178,123 @@ function withDefaults4(oldDefaults, newDefaults) { } var endpoint2 = withDefaults4(null, DEFAULTS2); var import_fast_content_type_parse2 = __toESM2(require_fast_content_type_parse2()); +var intRegex2 = /^-?\d+$/; +var noiseValue2 = /^-?\d+n+$/; +var originalStringify2 = JSON.stringify; +var originalParse2 = JSON.parse; +var customFormat2 = /^-?\d+n$/; +var bigIntsStringify2 = /([\[:])?"(-?\d+)n"($|([\\n]|\s)*(\s|[\\n])*[,\}\]])/g; +var noiseStringify2 = /([\[:])?("-?\d+n+)n("$|"([\\n]|\s)*(\s|[\\n])*[,\}\]])/g; +var JSONStringify2 = (value, replacer, space) => { + if ("rawJSON" in JSON) { + return originalStringify2( + value, + (key, value2) => { + if (typeof value2 === "bigint") + return JSON.rawJSON(value2.toString()); + if (typeof replacer === "function") + return replacer(key, value2); + if (Array.isArray(replacer) && replacer.includes(key)) + return value2; + return value2; + }, + space + ); + } + if (!value) + return originalStringify2(value, replacer, space); + const convertedToCustomJSON = originalStringify2( + value, + (key, value2) => { + const isNoise = typeof value2 === "string" && noiseValue2.test(value2); + if (isNoise) + return value2.toString() + "n"; + if (typeof value2 === "bigint") + return value2.toString() + "n"; + if (typeof replacer === "function") + return replacer(key, value2); + if (Array.isArray(replacer) && replacer.includes(key)) + return value2; + return value2; + }, + space + ); + const processedJSON = convertedToCustomJSON.replace( + bigIntsStringify2, + "$1$2$3" + ); + const denoisedJSON = processedJSON.replace(noiseStringify2, "$1$2$3"); + return denoisedJSON; +}; +var featureCache2 = /* @__PURE__ */ new Map(); +var isContextSourceSupported2 = () => { + const parseFingerprint = JSON.parse.toString(); + if (featureCache2.has(parseFingerprint)) { + return featureCache2.get(parseFingerprint); + } + try { + const result = JSON.parse( + "1", + (_, __, context3) => !!context3?.source && context3.source === "1" + ); + featureCache2.set(parseFingerprint, result); + return result; + } catch { + featureCache2.set(parseFingerprint, false); + return false; + } +}; +var convertMarkedBigIntsReviver2 = (key, value, context3, userReviver) => { + const isCustomFormatBigInt = typeof value === "string" && customFormat2.test(value); + if (isCustomFormatBigInt) + return BigInt(value.slice(0, -1)); + const isNoiseValue = typeof value === "string" && noiseValue2.test(value); + if (isNoiseValue) + return value.slice(0, -1); + if (typeof userReviver !== "function") + return value; + return userReviver(key, value, context3); +}; +var JSONParseV22 = (text, reviver) => { + return JSON.parse(text, (key, value, context3) => { + const isBigNumber = typeof value === "number" && (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER); + const isInt = context3 && intRegex2.test(context3.source); + const isBigInt = isBigNumber && isInt; + if (isBigInt) + return BigInt(context3.source); + if (typeof reviver !== "function") + return value; + return reviver(key, value, context3); + }); +}; +var MAX_INT2 = Number.MAX_SAFE_INTEGER.toString(); +var MAX_DIGITS2 = MAX_INT2.length; +var stringsOrLargeNumbers2 = /"(?:\\.|[^"])*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/g; +var noiseValueWithQuotes2 = /^"-?\d+n+"$/; +var JSONParse2 = (text, reviver) => { + if (!text) + return originalParse2(text, reviver); + if (isContextSourceSupported2()) + return JSONParseV22(text, reviver); + const serializedData = text.replace( + stringsOrLargeNumbers2, + (text2, digits, fractional, exponential) => { + const isString = text2[0] === '"'; + const isNoise = isString && noiseValueWithQuotes2.test(text2); + if (isNoise) + return text2.substring(0, text2.length - 1) + 'n"'; + const isFractionalOrExponential = fractional || exponential; + const isLessThanMaxSafeInt = digits && (digits.length < MAX_DIGITS2 || digits.length === MAX_DIGITS2 && digits <= MAX_INT2); + if (isString || isFractionalOrExponential || isLessThanMaxSafeInt) + return text2; + return '"' + text2 + 'n"'; + } + ); + return originalParse2( + serializedData, + (key, value, context3) => convertMarkedBigIntsReviver2(key, value, context3, reviver) + ); +}; var RequestError2 = class extends Error { name; /** @@ -29019,7 +29332,7 @@ var RequestError2 = class extends Error { this.request = requestCopy; } }; -var VERSION22 = "10.0.7"; +var VERSION22 = "10.0.8"; var defaults_default2 = { headers: { "user-agent": `octokit-request.js/${VERSION22} ${getUserAgent2()}` @@ -29046,7 +29359,7 @@ async function fetchWrapper2(requestOptions) { } const log = requestOptions.request?.log || console; const parseSuccessResponseBody = requestOptions.request?.parseSuccessResponseBody !== false; - const body = isPlainObject22(requestOptions.body) || Array.isArray(requestOptions.body) ? JSON.stringify(requestOptions.body) : requestOptions.body; + const body = isPlainObject22(requestOptions.body) || Array.isArray(requestOptions.body) ? JSONStringify2(requestOptions.body) : requestOptions.body; const requestHeaders = Object.fromEntries( Object.entries(requestOptions.headers).map(([name, value]) => [ name, @@ -29145,7 +29458,7 @@ async function getResponseData2(response) { let text = ""; try { text = await response.text(); - return JSON.parse(text); + return JSONParse2(text); } catch (err) { return text; } @@ -32269,6 +32582,54 @@ var types = ( return types2; }() ); +async function invokeWithRetry(fn, retries = 3, delay = 1e3) { + let attempt = 0; + while (attempt < retries) { + try { + return await fn(); + } catch (e) { + attempt++; + if (attempt >= retries) { + throw e; + } + if (isGithubApiError(e) && e.status < 500) { + throw e; + } + if (e instanceof GraphqlResponseError2) { + if (!e.errors) { + throw e; + } + if (e.errors.every((err) => ["NOT_FOUND", "FORBIDDEN", "BAD_USER_INPUT", "UNAUTHENTICATED"].includes(err.type))) { + throw e; + } + } + Log.warn(`GitHub API call failed (attempt ${attempt}/${retries}). Retrying in ${delay}ms...`); + await new Promise((resolve22) => setTimeout(resolve22, delay)); + } + } + throw new Error("Unreachable"); +} +function createRetryProxy(target) { + return new Proxy(target, { + get(targetObj, prop, receiver) { + const value = Reflect.get(targetObj, prop, receiver); + if (typeof value === "function") { + return new Proxy(value, { + apply(targetFn, thisArg, argArray) { + return invokeWithRetry(() => targetFn.apply(targetObj, argArray)); + } + }); + } + if (typeof value === "object" && value !== null) { + return createRetryProxy(value); + } + return value; + }, + apply(targetFn, thisArg, argArray) { + return invokeWithRetry(() => targetFn.apply(thisArg, argArray)); + } + }); +} var GithubClient = class { constructor(_octokitOptions) { this._octokitOptions = _octokitOptions; @@ -32281,18 +32642,18 @@ var GithubClient = class { }, ...this._octokitOptions }); - this.pulls = this._octokit.pulls; - this.orgs = this._octokit.orgs; - this.repos = this._octokit.repos; - this.issues = this._octokit.issues; - this.git = this._octokit.git; - this.rateLimit = this._octokit.rateLimit; - this.teams = this._octokit.teams; - this.search = this._octokit.search; - this.rest = this._octokit.rest; - this.paginate = this._octokit.paginate; - this.checks = this._octokit.checks; - this.users = this._octokit.users; + this.pulls = createRetryProxy(this._octokit.pulls); + this.orgs = createRetryProxy(this._octokit.orgs); + this.repos = createRetryProxy(this._octokit.repos); + this.issues = createRetryProxy(this._octokit.issues); + this.git = createRetryProxy(this._octokit.git); + this.rateLimit = createRetryProxy(this._octokit.rateLimit); + this.teams = createRetryProxy(this._octokit.teams); + this.search = createRetryProxy(this._octokit.search); + this.rest = createRetryProxy(this._octokit.rest); + this.paginate = createRetryProxy(this._octokit.paginate); + this.checks = createRetryProxy(this._octokit.checks); + this.users = createRetryProxy(this._octokit.users); } }; var AuthenticatedGithubClient = class extends GithubClient { @@ -32304,9 +32665,14 @@ var AuthenticatedGithubClient = class extends GithubClient { }); } async graphql(queryObject, params2 = {}) { - return await this._graphql(query(queryObject).toString(), params2); + return invokeWithRetry(async () => { + return await this._graphql(query(queryObject).toString(), params2); + }); } }; +function isGithubApiError(obj) { + return obj instanceof Error && obj.constructor.name === "RequestError" && obj.request !== void 0; +} function isDryRun() { return process.env["DRY_RUN"] !== void 0; } @@ -33020,24 +33386,28 @@ var requiresLabels = createTypedObject(RequiresLabel)({ description: "This PR requires a passing TGP before merging is allowed" } }); -var FeatureLabel = class extends Label { +var MiscLabel = class extends Label { }; -var featureLabels = createTypedObject(FeatureLabel)({ - FEATURE_IN_BACKLOG: { - name: "feature: in backlog", - description: "Feature request for which voting has completed and is now in the backlog" +var miscLabels = createTypedObject(MiscLabel)({ + FEATURE: { + name: "feature", + description: "Label used to distinguish feature request from other issues" + }, + GOOD_FIRST_ISSUE: { + name: "good first issue", + description: "Label noting a good first issue to be worked on by a community member" }, - FEATURE_VOTES_REQUIRED: { - name: "feature: votes required", - description: "Feature request which is currently still in the voting phase" + HELP_WANTED: { + name: "help wanted", + description: "Label noting an issue which the team is looking for contribution from the community to fix" }, - FEATURE_UNDER_CONSIDERATION: { - name: "feature: under consideration", - description: "Feature request for which voting has completed and the request is now under consideration" + RENOVATE_MANAGED: { + name: "renovate managed", + description: "Label noting that a pull request will automatically be managed and rebased by renovate" }, - FEATURE_INSUFFICIENT_VOTES: { - name: "feature: insufficient votes", - description: "Label to add when the not a sufficient number of votes or comments from unique authors" + GEMINI_TRIAGED: { + name: "gemini-triaged", + description: "Label noting that an issue has been triaged by gemini" } }); var allLabels = { @@ -33046,8 +33416,8 @@ var allLabels = { ...mergeLabels, ...targetLabels, ...priorityLabels, - ...featureLabels, - ...requiresLabels + ...requiresLabels, + ...miscLabels }; var import_which = __toESM2(require_lib2()); var import_yaml = __toESM2(require_dist()); @@ -33225,7 +33595,7 @@ tmp/lib/tmp.js: (* v8 ignore next -- @preserve *) (* v8 ignore else -- @preserve *) -@angular/ng-dev/bundles/chunk-ZTI2LCA3.mjs: +@angular/ng-dev/bundles/chunk-G7GMCCSS.mjs: (*! Bundled license information: yargs-parser/build/lib/string-utils.js: @@ -33266,7 +33636,7 @@ tmp/lib/tmp.js: *) *) -@angular/ng-dev/bundles/chunk-BZKO77AS.mjs: +@angular/ng-dev/bundles/chunk-PTDPQBIK.mjs: (*! Bundled license information: @octokit/request-error/dist-src/index.js: diff --git a/.github/actions/saucelabs-legacy/action.yml b/.github/actions/saucelabs-legacy/action.yml index 8d824418ba84..e5113c90e2e7 100644 --- a/.github/actions/saucelabs-legacy/action.yml +++ b/.github/actions/saucelabs-legacy/action.yml @@ -5,9 +5,9 @@ runs: using: 'composite' steps: - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Saucelabs Variables - uses: angular/dev-infra/github-actions/saucelabs@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/saucelabs@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Starting Saucelabs tunnel service shell: bash run: ./tools/saucelabs/sauce-service.sh run & diff --git a/.github/workflows/adev-preview-build.yml b/.github/workflows/adev-preview-build.yml index a80dbe389bb2..25dc95851f06 100644 --- a/.github/workflows/adev-preview-build.yml +++ b/.github/workflows/adev-preview-build.yml @@ -21,17 +21,17 @@ jobs: (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'adev: preview')) steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Build adev # `snapshot-build` config is used to stamp the exact version with sha in the footer. run: pnpm bazel build //adev:build.production --config=snapshot-build - - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: workflow-artifact-name: 'adev-preview' pull-number: '${{github.event.pull_request.number}}' diff --git a/.github/workflows/adev-preview-deploy.yml b/.github/workflows/adev-preview-deploy.yml index 6cdc5fd02fda..2c393536a671 100644 --- a/.github/workflows/adev-preview-deploy.yml +++ b/.github/workflows/adev-preview-deploy.yml @@ -32,15 +32,17 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: '${{secrets.GITHUB_TOKEN}}' + persist-credentials: false - name: Configure Firebase deploy target working-directory: ./ run: | # We can use `npx` as the Firebase deploy actions uses it too. - npx -y firebase-tools@latest target:clear --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs - npx -y firebase-tools@latest target:apply --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs ${{env.PREVIEW_SITE}} + # Use stable version release + npx -y firebase-tools@15.15.0 target:clear --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs + npx -y firebase-tools@15.15.0 target:apply --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs ${{env.PREVIEW_SITE}} - - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: github-token: '${{secrets.GITHUB_TOKEN}}' workflow-artifact-name: 'adev-preview' diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index a6238fd64aa8..87519e50b728 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -17,6 +17,6 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/branch-manager@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/benchmark-compare.yml b/.github/workflows/benchmark-compare.yml index ea84843b6e82..d240439249a9 100644 --- a/.github/workflows/benchmark-compare.yml +++ b/.github/workflows/benchmark-compare.yml @@ -25,7 +25,7 @@ jobs: token: '${{secrets.BENCHMARK_POST_RESULTS_GITHUB_TOKEN}}' reactions: 'rocket' - - uses: alessbell/pull-request-comment-branch@aad01d65d6982b8eacabed5e9a684cd8ceb98da6 # v1.1 + - uses: alessbell/pull-request-comment-branch@653a7d5ca8bd91d3c5cb83286063314d0b063b8e # v1.4.0 id: comment-branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -34,11 +34,23 @@ jobs: repository: ${{steps.comment-branch.outputs.head_owner}}/${{steps.comment-branch.outputs.head_repo}} # Checkout the pull request and assume it being trusted given we've checked # that the action was triggered by a team member. - ref: ${{steps.comment-branch.outputs.head_ref}} + ref: ${{steps.comment-branch.outputs.head_sha}} + + # We cannot use `angular/dev-infra/github-actions/npm/checkout-and-setup-node` here + # because it does not support checking out from a fork (as it lacks a `repository` input). + # Thus, we checkout and setup Node/pnpm manually. + - name: Install pnpm + uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' - run: pnpm install --frozen-lockfile - - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: bazelrc: ./.bazelrc.user @@ -49,7 +61,8 @@ jobs: COMMENT_BODY: ${{ github.event.comment.body }} run: pnpm benchmarks prepare-for-github-action "$COMMENT_BODY" - - run: pnpm benchmarks run-compare ${{steps.info.outputs.compareSha}} ${{steps.info.outputs.benchmarkTarget}} + - run: pnpm benchmarks run-compare ${{steps.info.outputs.compareSha}} "${{steps.info.outputs.benchmarkTarget}}" + id: benchmark name: Running benchmark diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4b6a34b41c9..4af82142f557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Check code lint @@ -32,6 +32,8 @@ jobs: run: pnpm ng-dev pullapprove verify - name: Validate angular robot configuration run: pnpm ng-dev ngbot verify + - name: Validate agent skills + run: pnpm ng-dev ai skills validate - name: Confirm code builds with typescript as expected run: pnpm check-tooling-setup @@ -39,13 +41,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: disable-package-manager-cache: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -55,7 +57,7 @@ jobs: - name: Test build run: pnpm devtools:build:chrome - name: Cypress run - uses: cypress-io/github-action@84d178e4bbce871e23f2ffa3085898cde0e4f0ec # v7.1.2 + uses: cypress-io/github-action@c495c3ddffba403ba11be95fffb67e25203b3799 # v7.1.10 with: command: pnpm devtools:test:e2e start: pnpm bazel run //devtools/src:devserver @@ -67,11 +69,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel Remote Caching - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -83,11 +85,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel Remote Caching - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -100,11 +102,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -119,11 +121,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -136,11 +138,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - run: echo "https://${{secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN}}:@github.com" > ${HOME}/.git_credentials @@ -152,11 +154,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -206,11 +208,11 @@ jobs: runs-on: ubuntu-latest-8core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Build adev diff --git a/.github/workflows/cross-repo-adev-docs.yml b/.github/workflows/cross-repo-adev-docs.yml index b5788ea63366..9bbf7e2440b9 100644 --- a/.github/workflows/cross-repo-adev-docs.yml +++ b/.github/workflows/cross-repo-adev-docs.yml @@ -37,7 +37,7 @@ jobs: ANGULAR_READONLY_GITHUB_TOKEN: ${{ secrets.READONLY_GITHUB_TOKEN }} - name: Create a PR (if necessary) - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.ANGULAR_ROBOT_ACCESS_TOKEN }} push-to-fork: 'angular-robot/angular' diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index e2099cb6a4d8..ac485d64f342 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -3,23 +3,36 @@ name: DevInfra on: pull_request_target: types: [opened, synchronize, reopened] + issues: + types: [opened, reopened] # Declare default permissions as read only. permissions: contents: read jobs: - labels: + pull_request_labels: + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: angular/dev-infra/github-actions/pull-request-labeling@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/labeling/pull-request@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + labels: '{"requires: TGP": ["packages/core/primitives/**/{*,.*}"]}' post_approval_changes: + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: angular/dev-infra/github-actions/post-approval-changes@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/post-approval-changes@ba726e7bca0b08b125ccc6f93c233749e1213c17 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@ba726e7bca0b08b125ccc6f93c233749e1213c17 + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }} diff --git a/.github/workflows/google-internal-tests.yml b/.github/workflows/google-internal-tests.yml index 85aeba5fd314..89e540f3859b 100644 --- a/.github/workflows/google-internal-tests.yml +++ b/.github/workflows/google-internal-tests.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: angular/dev-infra/github-actions/google-internal-tests@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/google-internal-tests@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: run-tests-guide-url: http://go/angular-g3sync-start github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 4901a69e045b..a40a990ab579 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -13,15 +13,15 @@ jobs: JOBS: 2 steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel Remote Caching - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Saucelabs Variables - uses: angular/dev-infra/github-actions/saucelabs@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/saucelabs@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Set up Sauce Tunnel Daemon run: pnpm bazel run //tools/saucelabs-daemon/background-service -- $JOBS & env: diff --git a/.github/workflows/merge-ready-status.yml b/.github/workflows/merge-ready-status.yml index 775b5ca6fae6..952b36fdde22 100644 --- a/.github/workflows/merge-ready-status.yml +++ b/.github/workflows/merge-ready-status.yml @@ -9,6 +9,6 @@ jobs: status: runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/unified-status-check@7c08ac2a4f396bad752829fba09dbaefbcded9fc + - uses: angular/dev-infra/github-actions/unified-status-check@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index ea71af9f177a..2d682a2cdec9 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -21,7 +21,7 @@ jobs: workflows: ${{ steps.workflows.outputs.workflows }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - id: workflows @@ -36,9 +36,9 @@ jobs: workflow: ${{ fromJSON(needs.list.outputs.workflows) }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - 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 ee5d250b307c..30c4a7b7016e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Check code lint @@ -32,12 +32,14 @@ jobs: run: pnpm ng-dev ngbot verify - name: Confirm code builds with typescript as expected run: pnpm check-tooling-setup + - name: Validate agent skills + run: pnpm ng-dev ai skills validate - name: Check commit message run: pnpm ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} - name: Check code format 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@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/linting/licenses@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: allow-dependencies-licenses: 'pkg:npm/google-protobuf@' @@ -45,13 +47,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 with: disable-package-manager-cache: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Run unit tests @@ -59,7 +61,7 @@ jobs: - name: Test build run: pnpm devtools:build:chrome - name: Cypress run - uses: cypress-io/github-action@84d178e4bbce871e23f2ffa3085898cde0e4f0ec # v7.1.2 + uses: cypress-io/github-action@c495c3ddffba403ba11be95fffb67e25203b3799 # v7.1.10 with: command: pnpm devtools:test:e2e start: pnpm bazel run //devtools/src:devserver @@ -71,11 +73,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel Remote Caching - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Run CI tests for framework @@ -95,11 +97,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel Remote Caching - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Run integration CI tests for framework @@ -110,11 +112,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests @@ -127,11 +129,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests @@ -142,11 +144,11 @@ jobs: labels: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/setup@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@7c08ac2a4f396bad752829fba09dbaefbcded9fc + uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17 - name: Install node modules run: pnpm install --frozen-lockfile - run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3598512789d3..242a2d7b663f 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: results.sarif diff --git a/.ng-dev/github.mjs b/.ng-dev/github.mjs index 8df9dcd86ffa..ac2ec5582825 100644 --- a/.ng-dev/github.mjs +++ b/.ng-dev/github.mjs @@ -9,5 +9,5 @@ export const github = { name: 'angular', mainBranchName: 'main', mergeMode: 'caretaker-only', - requireReleaseModeForRelease: true, + requireReleaseModeForRelease: false, }; diff --git a/.nvmrc b/.nvmrc index 85e502778f62..db49bb14d78e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.22.0 +22.22.2 diff --git a/.prettierignore b/.prettierignore index 707f48036864..07f2d18df45a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -30,6 +30,9 @@ vscode-ng-language-service/syntaxes/test/data/*.html # Ignore goldens MD files goldens/**/*.api.md +# Ignore golden symbol json files +packages/**/*.golden_symbols.json + # adev generated files adev/src/content/aria/**/*.json adev/src/content/cli/**/*.json diff --git a/.pullapprove.yml b/.pullapprove.yml index 9cd37692e09a..718fb00f8920 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -36,8 +36,9 @@ version: 3 -#availability: -# users_unavailable: [] +availability: + users_unavailable: + - devversion # Meta field that goes unused by PullApprove to allow for defining aliases to be # used throughout the config. @@ -99,14 +100,13 @@ groups: reviewers: users: - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - atscott - crisbeto - devversion - - thePunderWoman + - ~thePunderWoman - kirjs - JoostK - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck @@ -146,14 +146,13 @@ groups: reviewers: users: - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - atscott - crisbeto - devversion - kirjs - - thePunderWoman + - ~thePunderWoman - ~pkozlowski-opensource - - mmalerba - JeanMeche - ~amishne - ~leonsenft @@ -202,14 +201,13 @@ groups: users: - ~JiaLiPassion - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - atscott - crisbeto - devversion - kirjs - - thePunderWoman + - ~thePunderWoman - ~pkozlowski-opensource - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck @@ -246,24 +244,25 @@ groups: - > contains_any_globs(files, [ 'adev/**/{*,.*}', + 'tools/manual_api_docs/blocks/*.md', + 'tools/manual_api_docs/elements/*.md', ]) reviewers: users: - alan-agius4 - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - atscott - bencodezen - crisbeto - kirjs - JeanMeche - - thePunderWoman + - ~thePunderWoman - devversion - josephperrott - ~pkozlowski-opensource - ~mgechev - MarkTechson - - mmalerba - ~hawkgs - ~amishne - ~leonsenft @@ -299,7 +298,11 @@ groups: <<: *defaults conditions: - > - contains_any_globs(files.exclude('.pullapprove.yml'), [ + contains_any_globs(files + .exclude('.pullapprove.yml') + .exclude('tools/manual_api_docs/blocks/*.md') + .exclude('tools/manual_api_docs/elements/*.md'), + [ '{*,.*}', '.agent/**/{*,.*}', '.devcontainer/**/{*,.*}', @@ -370,13 +373,12 @@ groups: ]) reviewers: users: - - AndrewKushnir + - ~AndrewKushnir - ~alxhub - atscott - - thePunderWoman + - ~thePunderWoman - ~pkozlowski-opensource - kirjs - - mmalerba - crisbeto - devversion - JeanMeche @@ -407,12 +409,11 @@ groups: reviewers: users: - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - atscott - kirjs - - thePunderWoman + - ~thePunderWoman - ~pkozlowski-opensource - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck @@ -434,7 +435,7 @@ groups: ]) reviewers: users: - - marktechson + - MarkTechson - kirjs - ~JeanMeche - ~dgp1130 @@ -456,10 +457,10 @@ groups: reviewers: users: - ~alxhub - - AndrewKushnir + - ~AndrewKushnir - andrewseguin - dgp1130 - - thePunderWoman + - ~thePunderWoman - josephperrott # ========================================================= @@ -478,20 +479,16 @@ groups: users: - ~pkozlowski-opensource # Pawel Kozlowski - ~alxhub # Alex Rickabaugh - - thePunderWoman # Jessica Janiuk - - AndrewKushnir # Andrew Kushnir + - ~thePunderWoman # Jessica Janiuk + - ~AndrewKushnir # Andrew Kushnir - atscott # Andrew Scott - labels: - pending: 'requires: TGP' - approved: 'requires: TGP' - rejected: 'requires: TGP' # External team required reviews primitives-shared: <<: *defaults conditions: - > - contains_any_globs(files, [ + contains_any_globs(files.exclude('packages/core/primitives/**/*spec.ts'), [ 'packages/core/primitives/**/{*,.*}', ]) reviewers: @@ -502,10 +499,6 @@ groups: - tbondwilkinson # Tom Wilkinson - rahatarmanahmed # Rahat Ahmed - ENAML # Ethan Cline - labels: - pending: 'requires: TGP' - approved: 'requires: TGP' - rejected: 'requires: TGP' #################################################################################### # Override managed result groups diff --git a/CHANGELOG.md b/CHANGELOG.md index 001c367879ea..6624df7ef494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,178 +1,386 @@ - -# 21.1.5 (2026-02-18) + +# 21.2.17 (2026-06-10) +## Deprecations +### platform-server +- XHR support in `@angular/platform-server` is deprecated. Use standard `fetch` APIs instead. +### common +| Commit | Type | Description | +| -- | -- | -- | +| [86a56dc279](https://github.com/angular/angular/commit/86a56dc279e71159d09a073a3cb138f49131995b) | fix | Limits date format string length | +| [d846326b07](https://github.com/angular/angular/commit/d846326b071e0a4ab090e068d934b182926c6b15) | fix | skip transfer cache for uncacheable HTTP traffic | +| [bc55749698](https://github.com/angular/angular/commit/bc55749698ce3917160cd8e9f7108f3c5d1c0b32) | fix | use cryptographically secure SHA-256 for transfer cache key generation | +### compiler +| Commit | Type | Description | +| -- | -- | -- | +| [dc9c99636d](https://github.com/angular/angular/commit/dc9c99636d3471ed5a3c5cda54b95f604cd2b9a4) | fix | sanitize two-way properties | +### core +| Commit | Type | Description | +| -- | -- | -- | +| [1523061137](https://github.com/angular/angular/commit/152306113760e13196653699b42046d9f4129a37) | fix | harden TransferState restoration against DOM clobbering | +| [88832c84f8](https://github.com/angular/angular/commit/88832c84f8a3cd88d80adcde539a6f91a1f30b74) | fix | validate lowercase SVG animation attribute names ([#69269](https://github.com/angular/angular/pull/69269)) | +### http +| Commit | Type | Description | +| -- | -- | -- | +| [bcb1b7ea25](https://github.com/angular/angular/commit/bcb1b7ea2575b140f7bf202ad4f779e402cd6094) | fix | preserve empty referrer option in HttpRequest | +| [a810a319d1](https://github.com/angular/angular/commit/a810a319d10a8254307eb8f0598e7a888ce09ec0) | fix | Rejects non-HTTP(S) URLs in JSONP requests | +| [e245d40c4d](https://github.com/angular/angular/commit/e245d40c4d05665ab4814c594b8e0849b6e88a2d) | fix | skip transfer cache for fetch credentialed requests | +### platform-server +| Commit | Type | Description | +| -- | -- | -- | +| [35510746b7](https://github.com/angular/angular/commit/35510746b7d6b5c3de41de04c0586fc286c9e748) | fix | harden platform location origin validation during SSR | +| [13fb0afe93](https://github.com/angular/angular/commit/13fb0afe93b45e3c2383969f70d3ee1f0146ecfb) | refactor | deprecate ServerXhr ([#69255](https://github.com/angular/angular/pull/69255)) | +### service-worker +| Commit | Type | Description | +| -- | -- | -- | +| [b9d29381bb](https://github.com/angular/angular/commit/b9d29381bb4442164b19d9b7e0baa147a7b25629) | fix | Strips sensitive headers on cross-origin redirects | - -# 21.2.0-next.3 (2026-02-11) + +# 21.2.16 (2026-06-03) ### common | Commit | Type | Description | | -- | -- | -- | -| [18003a33bb](https://github.com/angular/angular/commit/18003a33bb0d6bb09def8a0e5939fa24069696eb) | feat | add an 'outlet' injector option for ngTemplateOutlet | -| [51cc914807](https://github.com/angular/angular/commit/51cc91480761b7275c15b5600381207f8ca00ee5) | feat | support height in ImageLoaderConfig and built-in loaders | +| [f6d8e642b0](https://github.com/angular/angular/commit/f6d8e642b0b215d2f9dbf1060abd24348c6cbf66) | fix | only strip a literal /index.html suffix from URLs | ### compiler | Commit | Type | Description | | -- | -- | -- | -| [11834a4274](https://github.com/angular/angular/commit/11834a42745e62830a83a4c14eea9d203baec680) | fix | add geolocation element to schema | -### compiler-cli +| [ae1c8a1f7a](https://github.com/angular/angular/commit/ae1c8a1f7a7f1d4832da3b22e3763864fa5ff098) | fix | move projection attributes into constants | +### core | Commit | Type | Description | | -- | -- | -- | -| [2ea6dfc6c9](https://github.com/angular/angular/commit/2ea6dfc6c9ca11e96a2654510c980419899f8d04) | fix | update diagnostic to flag no-op arrow functions in listeners | -### core +| [3fd6897a67](https://github.com/angular/angular/commit/3fd6897a67fd6acdc01fcde0452a98c3e0f81e21) | fix | harden inherit definition feature against polluted prototypes | +| [7e38336dc7](https://github.com/angular/angular/commit/7e38336dc73e14d98cc6465f54e1b7d6271facb2) | fix | use Object.create(null) for LOCALE_DATA as a hardening measure | +### platform-server | Commit | Type | Description | | -- | -- | -- | -| [ea2016a6dc](https://github.com/angular/angular/commit/ea2016a6dce58f95ecab7c773d5dcde274354e1a) | feat | add support for nested animations | -| [bd2868e915](https://github.com/angular/angular/commit/bd2868e915e78fb60583c00a11c778e3abf3ed8d) | fix | capture animation dependencies eagerly to avoid destroyed injector | -| [a7e8abbb7e](https://github.com/angular/angular/commit/a7e8abbb7e738ba338c3f50c76934c99925954e5) | fix | correctly handle SkipSelf when resolving from embedded view injector | -| [e53c8abaf9](https://github.com/angular/angular/commit/e53c8abaf9f09ca66b55f8169b9d723d1ffb640a) | fix | Fix flakey test due to document injection | -### forms +| [66821c4ed5](https://github.com/angular/angular/commit/66821c4ed5f580912a1609fc1e06a86f8793c2cf) | fix | throw on suspicious URLs and restrict protocol-relative URLs | +| [d3170031b6](https://github.com/angular/angular/commit/d3170031b6f35508f960cba18586843925bb61ec) | fix | update domino to latest version | + + + + +# 21.2.15 (2026-05-28) +### common | Commit | Type | Description | | -- | -- | -- | -| [f56bb07d83](https://github.com/angular/angular/commit/f56bb07d83a015b0ac12e74fdb0cf1550ff36b97) | feat | add field param to submit action and onInvalid | -| [ba009b6031](https://github.com/angular/angular/commit/ba009b603119299a03f9d844f93882d42d47d150) | feat | add form directive | -| [24c0c5a180](https://github.com/angular/angular/commit/24c0c5a180fab31438b1939e501f01b75e2d2760) | feat | support signal-based schemas in validateStandardSchema | -| [adfb83146b](https://github.com/angular/angular/commit/adfb83146b0c149734f43961563b389e00cc1d85) | fix | simplify design of parse errors | +| [7f4ac78994](https://github.com/angular/angular/commit/7f4ac78994bff1576ab33f3ce48f95c17f40b4d8) | fix | add upper bounds for digitsInfo | +| [300f61feb3](https://github.com/angular/angular/commit/300f61feb3a534bfddf16fcbd240f97b32249699) | fix | sanitize placeholder | +### compiler +| Commit | Type | Description | +| -- | -- | -- | +| [0b07f47bd6](https://github.com/angular/angular/commit/0b07f47bd6598ae6bd5b75a375e2c817a3c0f243) | fix | normalize tag names with custom namespaces in DomElementSchemaRegistry ([#68925](https://github.com/angular/angular/pull/68925)) | +| [eb1cbbf2eb](https://github.com/angular/angular/commit/eb1cbbf2eb5833219a367a61c04eb07aaa36cc29) | fix | prevent namespaced SVG a').toEqual([['Text', 'a']]); }); + + it('should not ignore namespaced SVG ').toEqual([ + ['Element', ':svg:svg'], + ['Element', ':svg:style'], + ['Text', '.a { fill: none; }'], + ]); + }); }); describe('', () => { diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index 88fe7586ab97..80a86ff593a2 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -8,113 +8,88 @@ import * as e from '../../../src/expression_parser/ast'; import * as a from '../../../src/render3/r3_ast'; -import {DirectiveMeta, InputOutputPropertySet} from '../../../src/render3/view/t2_api'; +import {DirectiveMeta, MatchSource} from '../../../src/render3/view/t2_api'; +import {ClassPropertyMapping} from '../../../src/property_mapping'; import {findMatchingDirectivesAndPipes, R3TargetBinder} from '../../../src/render3/view/t2_binder'; import {parseTemplate, ParseTemplateOptions} from '../../../src/render3/view/template'; import {CssSelector, SelectorlessMatcher, SelectorMatcher} from '../../../src/directive_matching'; import {findExpression} from './util'; -/** - * A `InputOutputPropertySet` which only uses an identity mapping for fields and properties. - */ -class IdentityInputMapping implements InputOutputPropertySet { - private names: Set; - - constructor(names: string[]) { - this.names = new Set(names); - } - - hasBindingPropertyName(propertyName: string): boolean { - return this.names.has(propertyName); - } +let keyCounter = 0; + +function makeDirectiveMeta(config: { + name: string; + selector: string | null; + inputs?: Record; + outputs?: Record; + exportAs?: string[]; + isComponent?: boolean; + isStructural?: boolean; + matchSource?: MatchSource; +}): DirectiveMeta { + return { + name: config.name, + ref: { + key: `${config.name}#${keyCounter++}`, + }, + exportAs: config.exportAs ?? null, + inputs: ClassPropertyMapping.fromMappedObject(config.inputs || {}), + outputs: ClassPropertyMapping.fromMappedObject(config.outputs || {}), + isComponent: !!config.isComponent, + isStructural: !!config.isStructural, + selector: config.selector, + animationTriggerNames: null, + ngContentSelectors: null, + preserveWhitespaces: false, + matchSource: config.matchSource ?? MatchSource.Selector, + }; } function makeSelectorMatcher(): SelectorMatcher { const matcher = new SelectorMatcher(); matcher.addSelectables(CssSelector.parse('[ngFor][ngForOf]'), [ - { + makeDirectiveMeta({ name: 'NgFor', - exportAs: null, - inputs: new IdentityInputMapping(['ngForOf']), - outputs: new IdentityInputMapping([]), - isComponent: false, - isStructural: true, + inputs: {ngForOf: 'ngForOf'}, selector: '[ngFor][ngForOf]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + isStructural: true, + }), ]); matcher.addSelectables(CssSelector.parse('[dir]'), [ - { + makeDirectiveMeta({ name: 'Dir', exportAs: ['dir'], - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping([]), - isComponent: false, - isStructural: false, selector: '[dir]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); matcher.addSelectables(CssSelector.parse('[hasOutput]'), [ - { + makeDirectiveMeta({ name: 'HasOutput', - exportAs: null, - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping(['outputBinding']), - isComponent: false, - isStructural: false, + outputs: {outputBinding: 'outputBinding'}, selector: '[hasOutput]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); matcher.addSelectables(CssSelector.parse('[hasInput]'), [ - { + makeDirectiveMeta({ name: 'HasInput', - exportAs: null, - inputs: new IdentityInputMapping(['inputBinding']), - outputs: new IdentityInputMapping([]), - isComponent: false, - isStructural: false, + inputs: {inputBinding: 'inputBinding'}, selector: '[hasInput]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); matcher.addSelectables(CssSelector.parse('[sameSelectorAsInput]'), [ - { + makeDirectiveMeta({ name: 'SameSelectorAsInput', - exportAs: null, - inputs: new IdentityInputMapping(['sameSelectorAsInput']), - outputs: new IdentityInputMapping([]), - isComponent: false, - isStructural: false, + inputs: {sameSelectorAsInput: 'sameSelectorAsInput'}, selector: '[sameSelectorAsInput]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); matcher.addSelectables(CssSelector.parse('comp'), [ - { + makeDirectiveMeta({ name: 'Comp', - exportAs: null, - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping([]), isComponent: true, - isStructural: false, selector: 'comp', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); const simpleDirectives = ['a', 'b', 'c', 'd', 'e', 'f']; @@ -122,18 +97,11 @@ function makeSelectorMatcher(): SelectorMatcher { for (const dir of [...simpleDirectives, ...deferBlockDirectives]) { const name = dir[0].toUpperCase() + dir.slice(1).toLowerCase(); matcher.addSelectables(CssSelector.parse(`[${dir}]`), [ - { + makeDirectiveMeta({ name: `Dir${name}`, - exportAs: null, - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping([]), - isComponent: false, isStructural: true, selector: `[${dir}]`, - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); } @@ -271,18 +239,10 @@ describe('t2 binding', () => { const template = parseTemplate('SVG', '', {}); const matcher = new SelectorMatcher(); matcher.addSelectables(CssSelector.parse('text[dir]'), [ - { + makeDirectiveMeta({ name: 'Dir', - exportAs: null, - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping([]), - isComponent: false, - isStructural: false, selector: 'text[dir]', - animationTriggerNames: null, - ngContentSelectors: null, - preserveWhitespaces: false, - }, + }), ]); const binder = new R3TargetBinder(matcher); const res = binder.bind({template: template.nodes}); @@ -1029,17 +989,6 @@ describe('t2 binding', () => { describe('selectorless', () => { const options: ParseTemplateOptions = {enableSelectorless: true}; - const baseMeta = { - selector: null, - inputs: new IdentityInputMapping([]), - outputs: new IdentityInputMapping([]), - exportAs: null, - isStructural: false, - ngContentSelectors: null, - preserveWhitespaces: false, - animationTriggerNames: null, - isComponent: false, - }; function makeSelectorlessMatcher( directives: (DirectiveMeta | {root: DirectiveMeta; additionalDirectives: DirectiveMeta[]})[], @@ -1064,26 +1013,21 @@ describe('t2 binding', () => { const binder = new R3TargetBinder( makeSelectorlessMatcher([ { - root: { - ...baseMeta, + root: makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, - additionalDirectives: [ - { - ...baseMeta, - name: 'MyHostDir', - }, - ], + }), + additionalDirectives: [makeDirectiveMeta({name: 'MyHostDir', selector: null})], }, - { - ...baseMeta, + makeDirectiveMeta({ name: 'Dir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'OtherDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1095,27 +1039,27 @@ describe('t2 binding', () => { const template = parseTemplate('', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, + }), { - root: { - ...baseMeta, + root: makeDirectiveMeta({ name: 'Dir', - }, + selector: null, + }), additionalDirectives: [ - { - ...baseMeta, + makeDirectiveMeta({ name: 'HostDir', - }, + selector: null, + }), ], }, - { - ...baseMeta, + makeDirectiveMeta({ name: 'OtherDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1128,14 +1072,14 @@ describe('t2 binding', () => { const template = parseTemplate('
', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'Dir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'OtherDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1147,11 +1091,11 @@ describe('t2 binding', () => { const template = parseTemplate('', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1166,10 +1110,10 @@ describe('t2 binding', () => { const template = parseTemplate('
', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'Dir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1200,13 +1144,13 @@ describe('t2 binding', () => { ); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - inputs: new IdentityInputMapping(['input', 'static']), - outputs: new IdentityInputMapping(['output']), - }, + inputs: {input: 'input', static: 'static'}, + outputs: {output: 'output'}, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1232,12 +1176,12 @@ describe('t2 binding', () => { ); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'Dir', - inputs: new IdentityInputMapping(['input', 'static']), - outputs: new IdentityInputMapping(['output']), - }, + selector: null, + inputs: {input: 'input', static: 'static'}, + outputs: {output: 'output'}, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1258,23 +1202,23 @@ describe('t2 binding', () => { const template = parseTemplate('', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, - { - ...baseMeta, + }), + makeDirectiveMeta({ name: 'Dir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'OtherDir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'UnusedDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1290,19 +1234,19 @@ describe('t2 binding', () => { const template = parseTemplate('@defer {}', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, - { - ...baseMeta, + }), + makeDirectiveMeta({ name: 'Dir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'OtherDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1330,19 +1274,19 @@ describe('t2 binding', () => { ); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, - { - ...baseMeta, + }), + makeDirectiveMeta({ name: 'Dir', - }, - { - ...baseMeta, + selector: null, + }), + makeDirectiveMeta({ name: 'UnusedDir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); @@ -1354,15 +1298,15 @@ describe('t2 binding', () => { const template = parseTemplate('', '', options); const binder = new R3TargetBinder( makeSelectorlessMatcher([ - { - ...baseMeta, + makeDirectiveMeta({ name: 'MyComp', + selector: null, isComponent: true, - }, - { - ...baseMeta, + }), + makeDirectiveMeta({ name: 'Dir', - }, + selector: null, + }), ]), ); const res = binder.bind({template: template.nodes}); diff --git a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts index 8a0576caf2cb..839a206c5082 100644 --- a/packages/compiler/test/schema/dom_element_schema_registry_spec.ts +++ b/packages/compiler/test/schema/dom_element_schema_registry_spec.ts @@ -11,14 +11,11 @@ import { DomElementSchemaRegistry, SCHEMA, } from '../../src/schema/dom_element_schema_registry'; -import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SecurityContext} from '@angular/core'; -import {isNode} from '@angular/private/testing'; +import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SecurityContext} from '../../src/core'; import {Element} from '../../src/ml_parser/ast'; import {HtmlParser} from '../../src/ml_parser/html_parser'; -import {extractSchema} from './schema_extractor'; - describe('DOMElementSchema', () => { let registry: DomElementSchemaRegistry; beforeEach(() => { @@ -157,6 +154,26 @@ If 'onAnything' is a directive input, make sure the directive is imported by the expect(registry.securityContext('a', 'href', false)).toBe(SecurityContext.URL); expect(registry.securityContext('a', 'style', false)).toBe(SecurityContext.STYLE); expect(registry.securityContext('base', 'href', false)).toBe(SecurityContext.RESOURCE_URL); + + // SVG animate and set attributes + expect(registry.securityContext(':svg:animate', 'to', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:animate', 'from', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:animate', 'values', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + expect(registry.securityContext(':svg:set', 'to', false)).toBe( + SecurityContext.ATTRIBUTE_NO_BINDING, + ); + + // SVG link attributes + expect(registry.securityContext(':svg:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'href', true)).toBe(SecurityContext.URL); + expect(registry.securityContext(':svg:a', 'xlink:href', true)).toBe(SecurityContext.URL); }); it('should detect properties on namespaced elements', () => { @@ -187,6 +204,23 @@ If 'onAnything' is a directive input, make sure the directive is imported by the }); }); + describe('Custom XML / XHTML namespaces', () => { + it('should support elements with custom namespaces', () => { + expect(registry.hasElement(':xhtml:a', [])).toBeTruthy(); + expect(registry.hasElement(':foo:div', [])).toBeTruthy(); + }); + + it('should support properties on custom namespaced elements', () => { + expect(registry.hasProperty(':xhtml:a', 'href', [])).toBeTruthy(); + expect(registry.hasProperty(':foo:div', 'id', [])).toBeTruthy(); + }); + + it('should return correct security contexts for custom namespaced elements', () => { + expect(registry.securityContext(':xhtml:a', 'href', false)).toBe(SecurityContext.URL); + expect(registry.securityContext(':foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML); + }); + }); + // Uncomment to see the generated schema which can then be pasted to the DomElementSchemaRegistry // if (!isNode) { // it('generate a new schema', () => { diff --git a/packages/compiler/test/schema/schema_extractor.ts b/packages/compiler/test/schema/schema_extractor.ts deleted file mode 100644 index 8ddfc00db44b..000000000000 --- a/packages/compiler/test/schema/schema_extractor.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -const SVG_PREFIX = ':svg:'; -const MATH_PREFIX = ':math:'; - -// Element | Node interfaces -// see https://developer.mozilla.org/en-US/docs/Web/API/Element -// see https://developer.mozilla.org/en-US/docs/Web/API/Node -const ELEMENT_IF = '[Element]'; -// HTMLElement interface -// see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement -const HTMLELEMENT_IF = '[HTMLElement]'; - -const HTMLELEMENT_TAGS = - 'abbr,address,article,aside,b,bdi,bdo,cite,content,code,dd,dfn,dt,em,figcaption,figure,footer,header,hgroup,i,kbd,main,mark,nav,noscript,rb,rp,rt,rtc,ruby,s,samp,search,section,small,strong,sub,sup,u,var,wbr'; - -const ALL_HTML_TAGS = - // https://www.w3.org/TR/html5/index.html - 'a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,content,data,datalist,dd,del,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,keygen,label,legend,li,link,main,map,mark,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,param,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,small,source,span,strong,style,sub,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr,' + - // https://html.spec.whatwg.org/ - 'details,summary,menu,menuitem,geolocation'; - -// Via https://developer.mozilla.org/en-US/docs/Web/MathML -const ALL_MATH_TAGS = - 'math,maction,menclose,merror,mfenced,mfrac,mi,mmultiscripts,mn,mo,mover,mpadded,mphantom,mroot,mrow,ms,mspace,msqrt,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,semantics'; - -// Elements missing from Chrome (HtmlUnknownElement), to be manually added -const MISSING_FROM_CHROME: {[el: string]: string[]} = { - 'data^[HTMLElement]': ['value'], - 'keygen^[HTMLElement]': ['!autofocus', 'challenge', '!disabled', 'form', 'keytype', 'name'], - // TODO(vicb): Figure out why Chrome and WhatWG do not agree on the props - // 'menu^[HTMLElement]': ['type', 'label'], - 'menuitem^[HTMLElement]': [ - 'type', - 'label', - 'icon', - '!disabled', - '!checked', - 'radiogroup', - '!default', - ], - 'summary^[HTMLElement]': [], - 'time^[HTMLElement]': ['dateTime'], - ':svg:cursor^:svg:': [], -}; - -const _G: any = - (typeof window != 'undefined' && window) || - (typeof global != 'undefined' && global) || - (typeof self != 'undefined' && self); - -const document: any = typeof _G['document'] == 'object' ? _G['document'] : null; - -export function extractSchema(): Map | null { - if (!document) return null; - const SVGGraphicsElement = _G['SVGGraphicsElement']; - if (!SVGGraphicsElement) return null; - - const element = document.createElement('video'); - const descMap: Map = new Map(); - const visited: {[name: string]: boolean} = {}; - - // HTML top level - extractProperties(Node, element, visited, descMap, ELEMENT_IF, ''); - extractProperties(Element, element, visited, descMap, ELEMENT_IF, ''); - extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_IF, ELEMENT_IF); - extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_TAGS, HTMLELEMENT_IF); - extractProperties(HTMLMediaElement, element, visited, descMap, 'media', HTMLELEMENT_IF); - - // SVG top level - const svgAnimation = document.createElementNS('http://www.w3.org/2000/svg', 'set'); - const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const svgFeFuncA = document.createElementNS('http://www.w3.org/2000/svg', 'feFuncA'); - const svgGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); - const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - - const SVGAnimationElement = _G['SVGAnimationElement']; - const SVGGeometryElement = _G['SVGGeometryElement']; - const SVGComponentTransferFunctionElement = _G['SVGComponentTransferFunctionElement']; - const SVGGradientElement = _G['SVGGradientElement']; - const SVGTextContentElement = _G['SVGTextContentElement']; - const SVGTextPositioningElement = _G['SVGTextPositioningElement']; - extractProperties(SVGElement, svgText, visited, descMap, SVG_PREFIX, HTMLELEMENT_IF); - - extractProperties( - SVGGraphicsElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'graphics', - SVG_PREFIX, - ); - extractProperties( - SVGAnimationElement, - svgAnimation, - visited, - descMap, - SVG_PREFIX + 'animation', - SVG_PREFIX, - ); - extractProperties( - SVGGeometryElement, - svgPath, - visited, - descMap, - SVG_PREFIX + 'geometry', - SVG_PREFIX, - ); - extractProperties( - SVGComponentTransferFunctionElement, - svgFeFuncA, - visited, - descMap, - SVG_PREFIX + 'componentTransferFunction', - SVG_PREFIX, - ); - extractProperties( - SVGGradientElement, - svgGradient, - visited, - descMap, - SVG_PREFIX + 'gradient', - SVG_PREFIX, - ); - extractProperties( - SVGTextContentElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'textContent', - SVG_PREFIX + 'graphics', - ); - extractProperties( - SVGTextPositioningElement, - svgText, - visited, - descMap, - SVG_PREFIX + 'textPositioning', - SVG_PREFIX + 'textContent', - ); - - // Get all element types - const types = Object.getOwnPropertyNames(window).filter((k) => /^(HTML|SVG).*?Element$/.test(k)); - - types.sort(); - - types.forEach((type) => { - extractRecursiveProperties(visited, descMap, (window as any)[type]); - }); - - // Add elements missed by Chrome auto-detection - Object.keys(MISSING_FROM_CHROME).forEach((elHierarchy) => { - descMap.set(elHierarchy, MISSING_FROM_CHROME[elHierarchy]); - }); - - // Needed because we're running tests against some older Android versions. - if (typeof MathMLElement !== 'undefined') { - // Math top level - const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math'); - extractProperties(MathMLElement, math, visited, descMap, MATH_PREFIX, HTMLELEMENT_IF); - - // This script is written under the assumption that each tag has a corresponding class name, e.g. - // `` -> `SVGCircleElement` however this doesn't hold for Math elements which are all - // `MathMLElement`. Furthermore, they don't have special property names, but rather are - // configured exclusively via attributes. Register them as plain elements that inherit from - // the top-level `:math` namespace. - ALL_MATH_TAGS.split(',').forEach((tag) => - descMap.set(`${MATH_PREFIX}${tag}^${MATH_PREFIX}`, []), - ); - } - - assertNoMissingTags(descMap); - - return descMap; -} - -function assertNoMissingTags(descMap: Map): void { - const extractedTags: string[] = []; - - Array.from(descMap.keys()).forEach((key: string) => { - extractedTags.push(...key.split('|')[0].split('^')[0].split(',')); - }); - - const missingTags = [ - ...ALL_HTML_TAGS.split(','), - ...(typeof MathMLElement === 'undefined' - ? [] - : ALL_MATH_TAGS.split(',').map((tag) => MATH_PREFIX + tag)), - ].filter((tag) => !extractedTags.includes(tag)); - - if (missingTags.length) { - throw new Error(`DOM schema misses tags: ${missingTags.join(',')}`); - } -} - -function extractRecursiveProperties( - visited: {[name: string]: boolean}, - descMap: Map, - type: Function, -): string { - const name = extractName(type)!; - - if (visited[name]) { - return name; - } - - let superName: string; - switch (name) { - case ELEMENT_IF: - // ELEMENT_IF is the top most interface (Element | Node) - superName = ''; - break; - case HTMLELEMENT_IF: - superName = ELEMENT_IF; - break; - default: - superName = extractRecursiveProperties( - visited, - descMap, - type.prototype.__proto__.constructor, - ); - } - - let instance: HTMLElement | null = null; - name.split(',').forEach((tagName) => { - instance = type['name'].startsWith('SVG') - ? document.createElementNS('http://www.w3.org/2000/svg', tagName.replace(SVG_PREFIX, '')) - : document.createElement(tagName); - - let htmlType: Function; - - switch (tagName) { - case 'cite': - // interface is `HTMLQuoteElement` - htmlType = HTMLElement; - break; - default: - htmlType = type; - } - - if (!(instance instanceof htmlType)) { - throw new Error(`Tag <${tagName}> is not an instance of ${htmlType['name']}`); - } - }); - - extractProperties(type, instance, visited, descMap, name, superName); - - return name; -} - -function extractProperties( - type: Function, - instance: any, - visited: {[name: string]: boolean}, - descMap: Map, - name: string, - superName: string, -) { - if (!type) return; - - visited[name] = true; - - const fullName = name + (superName ? '^' + superName : ''); - - const props: string[] = descMap.has(fullName) ? descMap.get(fullName)! : []; - - const prototype = type.prototype; - const keys = Object.getOwnPropertyNames(prototype); - - keys.sort(); - keys.forEach((name) => { - if (name.startsWith('on')) { - props.push('*' + name.slice(2)); - } else { - const typeCh = _TYPE_MNEMONICS[typeof instance[name]]; - const descriptor = Object.getOwnPropertyDescriptor(prototype, name); - const isSetter = descriptor && descriptor.set; - if (typeCh !== void 0 && !name.startsWith('webkit') && isSetter) { - props.push(typeCh + name); - } - } - }); - - // There is no point in using `Node.nodeValue`, filter it out - descMap.set(fullName, type === Node ? props.filter((p) => p != '%nodeValue') : props); -} - -function extractName(type: Function): string | null { - let name = type['name']; - - // The polyfill @webcomponents/custom-element/src/native-shim.js overrides the - // window.HTMLElement and does not have the name property. Check if this is the - // case and if so, set the name manually. - if (name === '' && type === HTMLElement) { - name = 'HTMLElement'; - } - - switch (name) { - // see https://www.w3.org/TR/html5/index.html - // TODO(vicb): generate this map from all the element types - case 'Element': - return ELEMENT_IF; - case 'HTMLElement': - return HTMLELEMENT_IF; - case 'HTMLImageElement': - return 'img'; - case 'HTMLAnchorElement': - return 'a'; - case 'HTMLDListElement': - return 'dl'; - case 'HTMLDirectoryElement': - return 'dir'; - case 'HTMLHeadingElement': - return 'h1,h2,h3,h4,h5,h6'; - case 'HTMLModElement': - return 'ins,del'; - case 'HTMLOListElement': - return 'ol'; - case 'HTMLParagraphElement': - return 'p'; - case 'HTMLQuoteElement': - return 'q,blockquote,cite'; - case 'HTMLTableCaptionElement': - return 'caption'; - case 'HTMLTableCellElement': - return 'th,td'; - case 'HTMLTableColElement': - return 'col,colgroup'; - case 'HTMLTableRowElement': - return 'tr'; - case 'HTMLTableSectionElement': - return 'tfoot,thead,tbody'; - case 'HTMLUListElement': - return 'ul'; - case 'SVGGraphicsElement': - return SVG_PREFIX + 'graphics'; - case 'SVGMPathElement': - return SVG_PREFIX + 'mpath'; - case 'SVGSVGElement': - return SVG_PREFIX + 'svg'; - case 'SVGTSpanElement': - return SVG_PREFIX + 'tspan'; - default: - const isSVG = name.startsWith('SVG'); - if (name.startsWith('HTML') || isSVG) { - name = name.replace('HTML', '').replace('SVG', '').replace('Element', ''); - if (isSVG && name.startsWith('FE')) { - name = 'fe' + name.substring(2); - } else if (name) { - name = name.charAt(0).toLowerCase() + name.substring(1); - } - return isSVG ? SVG_PREFIX + name : name.toLowerCase(); - } - } - - return null; -} - -const _TYPE_MNEMONICS: {[type: string]: string} = { - 'string': '', - 'number': '#', - 'boolean': '!', - 'object': '%', -}; diff --git a/packages/compiler/test/schema/trusted_types_sinks_spec.ts b/packages/compiler/test/schema/trusted_types_sinks_spec.ts index d6f8dc96be32..dca36afa89ab 100644 --- a/packages/compiler/test/schema/trusted_types_sinks_spec.ts +++ b/packages/compiler/test/schema/trusted_types_sinks_spec.ts @@ -13,6 +13,7 @@ describe('isTrustedTypesSink', () => { expect(isTrustedTypesSink('iframe', 'srcdoc')).toBeTrue(); expect(isTrustedTypesSink('p', 'innerHTML')).toBeTrue(); expect(isTrustedTypesSink('embed', 'src')).toBeTrue(); + expect(isTrustedTypesSink('iframe', 'src')).toBeTrue(); expect(isTrustedTypesSink('a', 'href')).toBeFalse(); expect(isTrustedTypesSink('base', 'href')).toBeFalse(); expect(isTrustedTypesSink('div', 'style')).toBeFalse(); diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 89b1a74efdab..5a22dd9cef65 100644 --- a/packages/compiler/test/shadow_css/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -368,33 +368,31 @@ describe('ShadowCss', () => { describe('comments', () => { // Comments should be kept in the same position as otherwise inline sourcemaps break due to // shift in lines. - it('should replace multiline comments with newline', () => { - expect(shim('/* b {c} */ b {c}', 'contenta')).toBe('\n b[contenta] {c}'); + it('should remove inline comments without adding extra lines', () => { + expect(shim('/* b {} */ b {}', 'contenta')).toBe(' b[contenta] {}'); }); - it('should replace multiline comments with newline in the original position', () => { - expect(shim('/* b {c}\n */ b {c}', 'contenta')).toBe('\n\n b[contenta] {c}'); + it('should preserve internal newlines from multiline comments', () => { + expect(shim('/* b {}\n */ b {}', 'contenta')).toBe('\n b[contenta] {}'); }); - it('should replace comments with newline in the original position', () => { - expect(shim('/* b {c} */ b {c} /* a {c} */ a {c}', 'contenta')).toBe( - '\n b[contenta] {c} \n a[contenta] {c}', + it('should remove multiple inline comments without adding extra lines', () => { + expect(shim('/* b {} */ b {} /* a {} */ a {}', 'contenta')).toBe( + ' b[contenta] {} a[contenta] {}', ); }); it('should keep sourceMappingURL comments', () => { - expect(shim('b {c} /*# sourceMappingURL=data:x */', 'contenta')).toBe( - 'b[contenta] {c} /*# sourceMappingURL=data:x */', + expect(shim('b {} /*# sourceMappingURL=data:x */', 'contenta')).toBe( + 'b[contenta] {} /*# sourceMappingURL=data:x */', ); - expect(shim('b {c}/* #sourceMappingURL=data:x */', 'contenta')).toBe( - 'b[contenta] {c}/* #sourceMappingURL=data:x */', + expect(shim('b {}/* #sourceMappingURL=data:x */', 'contenta')).toBe( + 'b[contenta] {}/* #sourceMappingURL=data:x */', ); }); it('should handle adjacent comments', () => { - expect(shim('/* comment 1 */ /* comment 2 */ b {c}', 'contenta')).toBe( - '\n \n b[contenta] {c}', - ); + expect(shim('/* comment 1 */ /* comment 2 */ b {}', 'contenta')).toBe(' b[contenta] {}'); }); }); }); diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 8079532b8677..a1132ce3e03e 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -1,3 +1,4 @@ +load("@bazel_lib//lib:write_source_files.bzl", "write_source_file") load("//adev/shared-docs/pipeline/api-gen:generate_api_docs.bzl", "generate_api_docs") load("//packages/common/locales:index.bzl", "generate_base_locale_file") load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_package", "ng_project", "ts_config", "tsec_test") @@ -151,3 +152,10 @@ genrule( outs = ["event-dispatch-contract.min.js"], cmd = "cp $< $@", ) + +write_source_file( + name = "dom_security_schema", + check_that_out_file_exists = False, + in_file = "//packages/compiler:src/schema/dom_security_schema.ts", + out_file = ":src/sanitization/dom_security_schema.ts", +) diff --git a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts index 8cbaa3c140e2..a274dd96801d 100644 --- a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts +++ b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts @@ -12,7 +12,7 @@ import { FakeNavigation, FakeNavigationCurrentEntryChangeEvent, } from '../fake_navigation'; -import {ensureDocument} from '@angular/private/testing'; +import {ensureDocument, timeout} from '@angular/private/testing'; ensureDocument(); @@ -671,7 +671,7 @@ describe('navigation', () => { }, }); locals.navigation.pushState(null, '', '/pushed'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(precommitHandlerCalled).toBeTrue(); expect(locals.navigation.currentEntry.url).toBe('https://test.com/pushed'); }); @@ -704,7 +704,7 @@ describe('navigation', () => { }); locals.navigation.pushState(null, '', '/pushed-throw'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.url).not.toBe('https://test.com/pushed-throw'); expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); }); @@ -717,7 +717,7 @@ describe('navigation', () => { }, }); locals.navigation.replaceState(null, '', '/replaced'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(precommitHandlerCalled).toBeTrue(); expect(locals.navigation.currentEntry.url).toBe('https://test.com/replaced'); }); @@ -733,7 +733,7 @@ describe('navigation', () => { }); locals.navigation.replaceState(null, '', '/replaced-reject'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(precommitHandlerCalled).toBeTrue(); const navigateEvent = locals.navigateEvents[locals.navigateEvents.length - 1]; expect(navigateEvent.signal.aborted).toBeTrue(); @@ -753,7 +753,7 @@ describe('navigation', () => { }); locals.navigation.replaceState(null, '', '/replaced-throw'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(precommitHandlerCalled).toBeTrue(); const navigateEvent = locals.navigateEvents[locals.navigateEvents.length - 1]; expect(navigateEvent.signal.aborted).toBeTrue(); @@ -765,7 +765,7 @@ describe('navigation', () => { it('correctly changes URL and replaces history entry by default', async () => { // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-push'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.pendingInterceptOptions.push({ precommitHandler: async (event) => { event.redirect('/redirected-from-push', {history: 'push'}); @@ -773,7 +773,7 @@ describe('navigation', () => { }); const originalNumEntries = locals.navigation.entries().length; locals.navigation.pushState(null, '', '/pushed'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.url).toBe('https://test.com/redirected-from-push'); expect(locals.navigation.entries().length).toBe(originalNumEntries + 1); @@ -782,7 +782,7 @@ describe('navigation', () => { it('correctly changes URL and replaces history entry when history: "replace"', async () => { // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-push'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.pendingInterceptOptions.push({ precommitHandler: async (event) => { event.redirect('/redirected-from-push', {history: 'replace'}); @@ -790,7 +790,7 @@ describe('navigation', () => { }); const originalNumEntries = locals.navigation.entries().length; locals.navigation.pushState(null, '', '/pushed'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.url).toBe('https://test.com/redirected-from-push'); // pushState (becomes entry) + redirect with replace (replaces that entry) @@ -807,7 +807,7 @@ describe('navigation', () => { const nextEvent = locals.nextNavigateEvent(); locals.navigation.pushState(null, '', '/pushed-for-info'); const e = await nextEvent; - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(e.info).toEqual(redirectInfo); }); @@ -819,7 +819,7 @@ describe('navigation', () => { }, }); locals.navigation.pushState(null, '', '/pushed-for-state'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.getState()).toEqual(redirectState); }); @@ -829,7 +829,7 @@ describe('navigation', () => { it('correctly changes URL and replaces history entry by default', async () => { // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-push'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.navigateEvents.length = 0; locals.pendingInterceptOptions.push({ @@ -839,7 +839,7 @@ describe('navigation', () => { }); const originalNumEntries = locals.navigation.entries().length; locals.navigation.replaceState(null, '', '/replaced-for-push'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.url).toBe( 'https://test.com/redirected-from-replace-push', @@ -851,7 +851,7 @@ describe('navigation', () => { it('correctly changes URL and replaces history entry when history: "replace"', async () => { // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-replace'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.navigateEvents.length = 0; locals.pendingInterceptOptions.push({ @@ -861,7 +861,7 @@ describe('navigation', () => { }); const originalNumEntries = locals.navigation.entries().length; locals.navigation.replaceState(null, '', '/replaced-for-replace'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.url).toBe( 'https://test.com/redirected-from-replace-replace', @@ -874,7 +874,7 @@ describe('navigation', () => { const redirectInfo = {isRedirectedReplace: true}; // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-info'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.navigateEvents.length = 0; locals.pendingInterceptOptions.push({ @@ -885,7 +885,7 @@ describe('navigation', () => { const redirectedEvent = locals.nextNavigateEvent(); // Redirected navigation locals.navigation.replaceState(null, '', '/replaced-for-info'); const e = await redirectedEvent; - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(e.info).toEqual(redirectInfo); }); @@ -894,7 +894,7 @@ describe('navigation', () => { const redirectState = {isRedirectedReplace: true}; // First, push a state locals.navigation.pushState(null, '', '/initial-for-replace-state'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); locals.pendingInterceptOptions.push({ precommitHandler: async (event) => { @@ -902,7 +902,7 @@ describe('navigation', () => { }, }); locals.navigation.replaceState(null, '', '/replaced-for-state'); - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); expect(locals.navigation.currentEntry.getState()).toEqual(redirectState); }); @@ -1492,7 +1492,7 @@ describe('navigation', () => { // Determine if we need to await committed/finished for go() or if nextNavigateEvent is enough. // go() uses internal mechanism, but eventually updates currentEntry. // We can wait for the loop to settle. - await new Promise((resolve) => setTimeout(resolve, 0)); + await timeout(); expect(locals.navigation.currentEntry.key).toBe(thirdPageEntry.key); }); }); diff --git a/packages/core/primitives/event-dispatch/src/earlyeventcontract.ts b/packages/core/primitives/event-dispatch/src/earlyeventcontract.ts index 3d37b5dc4d54..cd81ea90cad3 100644 --- a/packages/core/primitives/event-dispatch/src/earlyeventcontract.ts +++ b/packages/core/primitives/event-dispatch/src/earlyeventcontract.ts @@ -140,3 +140,6 @@ function removeEventListeners( container.removeEventListener(eventTypes[i], earlyEventHandler, /* useCapture */ capture); } } + +// This fixes the RollupError: Exported variable "global" is not defined. +export {}; diff --git a/packages/core/primitives/event-dispatch/src/event_contract_container.ts b/packages/core/primitives/event-dispatch/src/event_contract_container.ts index f6cefd40d439..e378be8e724c 100644 --- a/packages/core/primitives/event-dispatch/src/event_contract_container.ts +++ b/packages/core/primitives/event-dispatch/src/event_contract_container.ts @@ -23,11 +23,6 @@ export interface EventContractContainerManager { cleanUp(): void; } -/** - * Whether the user agent is running on iOS. - */ -const isIos = typeof navigator !== 'undefined' && /iPhone|iPad|iPod/.test(navigator.userAgent); - /** * A class representing a container node and all the event handlers * installed on it. Used so that handlers can be cleaned up if the @@ -56,20 +51,6 @@ export class EventContractContainer implements EventContractContainerManager { getHandler: (element: Element) => (event: Event) => void, passive?: boolean, ) { - // In iOS, event bubbling doesn't happen automatically in any DOM element, - // unless it has an onclick attribute or DOM event handler attached to it. - // This breaks JsAction in some cases. See "Making Elements Clickable" - // section at http://goo.gl/2VoGnB. - // - // A workaround for this issue is to change the CSS cursor style to 'pointer' - // for the container element, which magically turns on event bubbling. This - // solution is described in the comments section at http://goo.gl/6pEO1z. - // - // We use a navigator.userAgent check here as this problem is present both - // on Mobile Safari and thin WebKit wrappers, such as Chrome for iOS. - if (isIos) { - (this.element as HTMLElement).style.cursor = 'pointer'; - } this.handlerInfos.push( eventLib.addEventListener(this.element, eventType, getHandler(this.element), passive), ); diff --git a/packages/core/primitives/event-dispatch/src/event_dispatcher.ts b/packages/core/primitives/event-dispatch/src/event_dispatcher.ts index 27f32a1d7aca..a549e3d8b925 100644 --- a/packages/core/primitives/event-dispatch/src/event_dispatcher.ts +++ b/packages/core/primitives/event-dispatch/src/event_dispatcher.ts @@ -190,3 +190,6 @@ export function registerDispatcher( dispatcher.dispatch(eventInfo); }, Restriction.I_AM_THE_JSACTION_FRAMEWORK); } + +// This fixes the RollupError: Exported variable "global" is not defined. +export {}; diff --git a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts index 119c2eb8f316..c2d64cd43900 100644 --- a/packages/core/primitives/event-dispatch/test/dispatcher_test.ts +++ b/packages/core/primitives/event-dispatch/test/dispatcher_test.ts @@ -879,31 +879,6 @@ describe('Dispatcher', () => { expect(keydownEvent.preventDefault).toHaveBeenCalled(); }); - - it('prevents default for enter key on anchor child', () => { - const container = getRequiredElementById('a11y-anchor-click-container'); - const actionElement = getRequiredElementById('a11y-anchor-click-action-element'); - const targetElement = getRequiredElementById('a11y-anchor-click-target-element'); - - const eventContract = createEventContract({ - container, - eventTypes: ['click', 'keydown'], - }); - const dispatchDelegate = createDispatchDelegateSpy(); - createDispatcher({dispatchDelegate, eventContract, a11yClickSupport: true}); - - const keydownEvent = dispatchKeyboardEvent(targetElement, {key: 'Enter'}); - - expect(dispatchDelegate).toHaveBeenCalledTimes(1); - const eventInfoWrapper = dispatchDelegate.calls.mostRecent().args[0]; - expect(eventInfoWrapper.getEventType()).toBe('click'); - expect(eventInfoWrapper.getEvent()).toBe(keydownEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - - expect(keydownEvent.preventDefault).toHaveBeenCalled(); - }); }); describe('non-bubbling mouse events', () => { diff --git a/packages/core/primitives/event-dispatch/test/event_test.ts b/packages/core/primitives/event-dispatch/test/event_test.ts index 49ed1b467abe..f590dbd9a212 100644 --- a/packages/core/primitives/event-dispatch/test/event_test.ts +++ b/packages/core/primitives/event-dispatch/test/event_test.ts @@ -459,6 +459,8 @@ describe('event test.ts', () => { target: child, } as unknown as Event; + expect(jsactionEvent.isMouseSpecialEvent(event, EventType.POINTERLEAVE, child)).toBe(false); + expect(jsactionEvent.isMouseSpecialEvent(event, EventType.POINTERLEAVE, child)).toBe(false); expect(jsactionEvent.isMouseSpecialEvent(event, EventType.MOUSELEAVE, child)).toBe(false); expect(jsactionEvent.isMouseSpecialEvent(event, EventType.MOUSELEAVE, child)).toBe(false); }); @@ -577,20 +579,6 @@ describe('event test.ts', () => { expect(jsactionEvent.isMouseSpecialEvent(event, EventType.POINTERLEAVE, subchild)).toBe(true); }); - it('is mouse special event not mouse', () => { - const root = document.createElement('div'); - const child = document.createElement('div'); - root.appendChild(child); - const event = { - relatedTarget: root, - type: EventType.CLICK, - target: child, - } as unknown as Event; - - expect(jsactionEvent.isMouseSpecialEvent(event, EventType.POINTERLEAVE, child)).toBe(false); - expect(jsactionEvent.isMouseSpecialEvent(event, EventType.POINTERLEAVE, child)).toBe(false); - }); - it('create mouse special event pointerenter', () => { const div = document.createElement('div'); const originalEvent = document.createEvent('MouseEvent'); diff --git a/packages/core/primitives/signals/src/computed.ts b/packages/core/primitives/signals/src/computed.ts index b6d5ceb7f42f..cad637cd9c26 100644 --- a/packages/core/primitives/signals/src/computed.ts +++ b/packages/core/primitives/signals/src/computed.ts @@ -14,9 +14,9 @@ import { producerUpdateValueVersion, REACTIVE_NODE, ReactiveNode, + runPostProducerCreatedFn, setActiveConsumer, SIGNAL, - runPostProducerCreatedFn, } from './graph'; // Required as the signals library is in a separate package, so we need to explicitly ensure the @@ -83,8 +83,8 @@ export function createComputed( (computed as ComputedGetter)[SIGNAL] = node; if (typeof ngDevMode !== 'undefined' && ngDevMode) { - const debugName = node.debugName ? ' (' + node.debugName + ')' : ''; - computed.toString = () => `[Computed${debugName}: ${String(node.value)}]`; + computed.toString = () => + `[Computed${node.debugName ? ' (' + node.debugName + ')' : ''}: ${String(node.value)}]`; } runPostProducerCreatedFn(node); @@ -114,8 +114,7 @@ export const ERRORED: any = /* @__PURE__ */ Symbol('ERRORED'); // Note: Using an IIFE here to ensure that the spread assignment is not considered // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -const COMPUTED_NODE = /* @__PURE__ */ (() => { +const COMPUTED_NODE: Omit, 'computation'> = /* @__PURE__ */ (() => { return { ...REACTIVE_NODE, value: UNSET, diff --git a/packages/core/primitives/signals/src/formatter.ts b/packages/core/primitives/signals/src/formatter.ts index 101532954ce5..19ceefec8dc1 100644 --- a/packages/core/primitives/signals/src/formatter.ts +++ b/packages/core/primitives/signals/src/formatter.ts @@ -159,3 +159,6 @@ export function installDevToolsSignalFormatter() { globalThis.devtoolsFormatters.push(formatter); } } + +// This fixes the RollupError: Exported variable "global" is not defined. +export {}; diff --git a/packages/core/primitives/signals/src/linked_signal.ts b/packages/core/primitives/signals/src/linked_signal.ts index eb66d3c80cae..30d2843c7c40 100644 --- a/packages/core/primitives/signals/src/linked_signal.ts +++ b/packages/core/primitives/signals/src/linked_signal.ts @@ -17,6 +17,7 @@ import { REACTIVE_NODE, ReactiveNode, runPostProducerCreatedFn, + setActiveConsumer, SIGNAL, } from './graph'; import {signalSetFn, signalUpdateFn} from './signal'; @@ -93,8 +94,8 @@ export function createLinkedSignal( const getter = linkedSignalGetter as LinkedSignalGetter; getter[SIGNAL] = node; if (typeof ngDevMode !== 'undefined' && ngDevMode) { - const debugName = node.debugName ? ' (' + node.debugName + ')' : ''; - getter.toString = () => `[LinkedSignal${debugName}: ${String(node.value)}]`; + getter.toString = () => + `[LinkedSignal${node.debugName ? ' (' + node.debugName + ')' : ''}: ${String(node.value)}]`; } runPostProducerCreatedFn(node); @@ -123,8 +124,10 @@ export function linkedSignalUpdateFn( // Note: Using an IIFE here to ensure that the spread assignment is not considered // a side-effect, ending up preserving `LINKED_SIGNAL_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -export const LINKED_SIGNAL_NODE: object = /* @__PURE__ */ (() => { +export const LINKED_SIGNAL_NODE: Omit< + LinkedSignalNode, + 'computation' | 'source' | 'sourceValue' +> = /* @__PURE__ */ (() => { return { ...REACTIVE_NODE, value: UNSET, @@ -152,17 +155,22 @@ export const LINKED_SIGNAL_NODE: object = /* @__PURE__ */ (() => { const prevConsumer = consumerBeforeComputation(node); let newValue: unknown; + let wasEqual = false; try { const newSourceValue = node.source(); - const prev = - oldValue === UNSET || oldValue === ERRORED - ? undefined - : { - source: node.sourceValue, - value: oldValue, - }; + const oldValueValid = oldValue !== UNSET && oldValue !== ERRORED; + const prev = oldValueValid + ? { + source: node.sourceValue, + value: oldValue, + } + : undefined; newValue = node.computation(newSourceValue, prev); node.sourceValue = newSourceValue; + // We want to mark this node as errored if calling `equal` throws; however, we don't want + // to track any reactive reads inside `equal`. + setActiveConsumer(null); + wasEqual = oldValueValid && newValue !== ERRORED && node.equal(oldValue, newValue); } catch (err) { newValue = ERRORED; node.error = err; @@ -170,7 +178,7 @@ export const LINKED_SIGNAL_NODE: object = /* @__PURE__ */ (() => { consumerAfterComputation(node, prevConsumer); } - if (oldValue !== UNSET && newValue !== ERRORED && node.equal(oldValue, newValue)) { + if (wasEqual) { // No change to `valueVersion` - old and new values are // semantically equivalent. node.value = oldValue; diff --git a/packages/core/primitives/signals/src/signal.ts b/packages/core/primitives/signals/src/signal.ts index d8ab44d44c20..eb1eaf53fc40 100644 --- a/packages/core/primitives/signals/src/signal.ts +++ b/packages/core/primitives/signals/src/signal.ts @@ -62,8 +62,8 @@ export function createSignal( const getter = (() => signalGetFn(node)) as SignalGetter; (getter as any)[SIGNAL] = node; if (typeof ngDevMode !== 'undefined' && ngDevMode) { - const debugName = node.debugName ? ' (' + node.debugName + ')' : ''; - getter.toString = () => `[Signal${debugName}: ${String(node.value)}]`; + getter.toString = () => + `[Signal${node.debugName ? ' (' + node.debugName + ')' : ''}: ${String(node.value)}]`; } runPostProducerCreatedFn(node); @@ -108,7 +108,6 @@ export function runPostSignalSetFn(node: SignalNode): void { // Note: Using an IIFE here to ensure that the spread assignment is not considered // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. export const SIGNAL_NODE: SignalNode = /* @__PURE__ */ (() => { return { ...REACTIVE_NODE, diff --git a/packages/core/primitives/signals/src/watch.ts b/packages/core/primitives/signals/src/watch.ts index f9ed2402f273..56eea164ecb1 100644 --- a/packages/core/primitives/signals/src/watch.ts +++ b/packages/core/primitives/signals/src/watch.ts @@ -140,8 +140,7 @@ const NOOP_CLEANUP_FN: WatchCleanupFn = () => {}; // Note: Using an IIFE here to ensure that the spread assignment is not considered // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -const WATCH_NODE: Partial = /* @__PURE__ */ (() => { +const WATCH_NODE: Omit = /* @__PURE__ */ (() => { return { ...REACTIVE_NODE, consumerIsAlwaysLive: true, diff --git a/packages/core/rxjs-interop/src/rx_resource.ts b/packages/core/rxjs-interop/src/rx_resource.ts index e0dd7a7a57de..8390b7be6ef9 100644 --- a/packages/core/rxjs-interop/src/rx_resource.ts +++ b/packages/core/rxjs-interop/src/rx_resource.ts @@ -6,19 +6,19 @@ * found in the LICENSE file at https://angular.dev/license */ +import {Observable, Subscription} from 'rxjs'; import { assertInInjectionContext, + BaseResourceOptions, resource, ResourceLoaderParams, ResourceRef, + ResourceStreamItem, Signal, signal, - BaseResourceOptions, ɵRuntimeError, ɵRuntimeErrorCode, - ResourceStreamItem, } from '../../src/core'; -import {Observable, Subscription} from 'rxjs'; import {encapsulateResourceError} from '../../src/resource/resource'; /** @@ -75,8 +75,7 @@ export function rxResource(opts: RxResourceOptions): ResourceRef stream - const streamFn = opts.stream ?? (opts as {loader?: RxResourceOptions['stream']}).loader; + const streamFn = opts.stream; if (streamFn === undefined) { throw new ɵRuntimeError( ɵRuntimeErrorCode.MUST_PROVIDE_STREAM_OPTION, diff --git a/packages/core/rxjs-interop/test/pending_until_event_spec.ts b/packages/core/rxjs-interop/test/pending_until_event_spec.ts index be90b6a8aa7f..a021e39d787e 100644 --- a/packages/core/rxjs-interop/test/pending_until_event_spec.ts +++ b/packages/core/rxjs-interop/test/pending_until_event_spec.ts @@ -23,6 +23,7 @@ import { import {pendingUntilEvent} from '../src'; import {TestBed} from '../../testing'; +import {timeout} from '@angular/private/testing'; describe('pendingUntilEvent', () => { let taskService: PendingTasksInternal; @@ -230,21 +231,21 @@ describe('pendingUntilEvent', () => { sub.next(); observable.subscribe(); // first subscription unblocks - await new Promise((r) => setTimeout(r, 5)); + await timeout(5); // still pending because the other subscribed after the emit expect(taskService.hasPendingTasks).toBe(true); sub.next(); - await new Promise((r) => setTimeout(r, 3)); + await timeout(3); observable.subscribe(); sub.next(); // second subscription unblocks - await new Promise((r) => setTimeout(r, 2)); + await timeout(2); // still pending because third subscription delay not finished expect(taskService.hasPendingTasks).toBe(true); // finishes third subscription - await new Promise((r) => setTimeout(r, 3)); + await timeout(3); await expectAsync(appRef.whenStable()).toBeResolved(); }); }); diff --git a/packages/core/rxjs-interop/test/rx_resource_spec.ts b/packages/core/rxjs-interop/test/rx_resource_spec.ts index 3bfae094a485..143d3fa351ba 100644 --- a/packages/core/rxjs-interop/test/rx_resource_spec.ts +++ b/packages/core/rxjs-interop/test/rx_resource_spec.ts @@ -8,6 +8,7 @@ import {of, Observable, BehaviorSubject, throwError} from 'rxjs'; import {TestBed} from '../../testing'; +import {timeout} from '@angular/private/testing'; import {ApplicationRef, Injector, signal} from '../../src/core'; import {rxResource} from '../src'; @@ -117,6 +118,6 @@ describe('rxResource()', () => { async function waitFor(fn: () => boolean): Promise { while (!fn()) { - await new Promise((resolve) => setTimeout(resolve, 1)); + await timeout(1); } } diff --git a/packages/core/schematics/migrations/ngclass-to-class-migration/util.ts b/packages/core/schematics/migrations/ngclass-to-class-migration/util.ts index b4e57f718f26..f53bbd1fc3e4 100644 --- a/packages/core/schematics/migrations/ngclass-to-class-migration/util.ts +++ b/packages/core/schematics/migrations/ngclass-to-class-migration/util.ts @@ -178,14 +178,23 @@ function getPropertyRemovalRange(property: ts.ObjectLiteralElementLike): { const properties = parent.properties; const propertyIndex = properties.indexOf(property); - const end = property.getEnd(); - if (propertyIndex < properties.length - 1) { - const nextProperty = properties[propertyIndex + 1]; - return {start: property.getStart(), end: nextProperty.getStart()}; + if (properties.length === 1) { + const sourceFile = property.getSourceFile(); + let end = property.getEnd(); + const textAfter = sourceFile.text.substring(end, parent.getEnd()); + const commaIndex = textAfter.indexOf(','); + if (commaIndex !== -1) { + end += commaIndex + 1; + } + return {start: property.getFullStart(), end}; + } + + if (propertyIndex === 0) { + return {start: property.getFullStart(), end: properties[1].getFullStart()}; } - return {start: property.getStart(), end}; + return {start: properties[propertyIndex - 1].getEnd(), end: property.getEnd()}; } export function calculateImportReplacements( diff --git a/packages/core/schematics/migrations/ngstyle-to-style-migration/util.ts b/packages/core/schematics/migrations/ngstyle-to-style-migration/util.ts index 474c991d90e4..b7a1e92fb110 100644 --- a/packages/core/schematics/migrations/ngstyle-to-style-migration/util.ts +++ b/packages/core/schematics/migrations/ngstyle-to-style-migration/util.ts @@ -164,14 +164,23 @@ function getPropertyRemovalRange(property: ts.ObjectLiteralElementLike): { const properties = parent.properties; const propertyIndex = properties.indexOf(property); - const end = property.getEnd(); - if (propertyIndex < properties.length - 1) { - const nextProperty = properties[propertyIndex + 1]; - return {start: property.getStart(), end: nextProperty.getStart()}; + if (properties.length === 1) { + const sourceFile = property.getSourceFile(); + let end = property.getEnd(); + const textAfter = sourceFile.text.substring(end, parent.getEnd()); + const commaIndex = textAfter.indexOf(','); + if (commaIndex !== -1) { + end += commaIndex + 1; + } + return {start: property.getFullStart(), end}; + } + + if (propertyIndex === 0) { + return {start: property.getFullStart(), end: properties[1].getFullStart()}; } - return {start: property.getStart(), end}; + return {start: properties[propertyIndex - 1].getEnd(), end: property.getEnd()}; } export function calculateImportReplacements( diff --git a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts index 905d85e4194f..cd27b3dab6d7 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts @@ -198,6 +198,35 @@ describe('outputs', () => { }); }); + it('should not insert a TODO comment for emit function with void type', async () => { + await verify({ + before: ` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + + someMethod(): void { + this.someChange.emit(); + } + } + `, + after: ` + import {Directive, output} from '@angular/core'; + + @Directive() + export class TestDir { + readonly someChange = output(); + + someMethod(): void { + this.someChange.emit(); + } + } + `, + }); + }); + it('should insert a TODO comment for emit function with type', async () => { await verify({ before: ` diff --git a/packages/core/schematics/migrations/output-migration/output-migration.ts b/packages/core/schematics/migrations/output-migration/output-migration.ts index ecee9bc66ac0..ae3e7bca3049 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.ts @@ -477,7 +477,7 @@ function addCommentForEmptyEmit( if (!propertyDeclaration) return; const eventEmitterType = getEventEmitterArgumentType(propertyDeclaration); - if (!eventEmitterType) return; + if (!eventEmitterType || eventEmitterType === 'void') return; const id = getUniqueIdForProperty(info, propertyDeclaration); const file = projectFile(node.getSourceFile(), info); diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/reference_resolution/template_reference_visitor.ts b/packages/core/schematics/migrations/signal-migration/src/passes/reference_resolution/template_reference_visitor.ts index 04d81bdab969..e522fe8c1d84 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/reference_resolution/template_reference_visitor.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/reference_resolution/template_reference_visitor.ts @@ -25,7 +25,9 @@ import { TmplAstBoundText, TmplAstDeferredBlock, TmplAstForLoopBlock, + TmplAstIcu, TmplAstIfBlockBranch, + TmplAstLetDeclaration, TmplAstNode, TmplAstRecursiveVisitor, TmplAstSwitchBlock, @@ -223,6 +225,21 @@ export class TemplateReferenceVisitor< this.templateAttributeReferencedFields.push(...referencedFields); } } + + override visitLetDeclaration(decl: TmplAstLetDeclaration): void { + this.checkExpressionForReferencedFields(decl, decl.value); + } + + override visitIcu(icu: TmplAstIcu): void { + for (const v of Object.values(icu.vars)) { + this.checkExpressionForReferencedFields(icu, v.value); + } + for (const p of Object.values(icu.placeholders)) { + if (p instanceof TmplAstBoundText) { + this.checkExpressionForReferencedFields(icu, p.value); + } + } + } } /** @@ -340,14 +357,19 @@ export class TemplateExpressionReferenceVisitor< } const symbol = this.templateTypeChecker.getSymbolOfNode(ast, this.componentClass); - if (symbol?.kind !== SymbolKind.Expression || symbol.tsSymbol === null) { + if (symbol?.kind !== SymbolKind.Expression) { + return false; + } + + const tsSymbol = this.templateTypeChecker.getTsSymbolOfSymbol(symbol); + if (tsSymbol === null) { return false; } // Dangerous: Type checking symbol retrieval is a totally different `ts.Program`, // than the one where we analyzed `knownInputs`. // --> Find the input via its input id. - const targetInput = this.knownFields.attemptRetrieveDescriptorFromSymbol(symbol.tsSymbol); + const targetInput = this.knownFields.attemptRetrieveDescriptorFromSymbol(tsSymbol); if (targetInput === null) { return false; diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/template_icu.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/template_icu.ts new file mode 100644 index 000000000000..2b089aa62324 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/template_icu.ts @@ -0,0 +1,16 @@ +// tslint:disable + +import {Component, Input} from '@angular/core'; + +@Component({ + template: ` + {foo, plural, + =0 {{bar, plural, =0 {zero} other {zero, bar is {{ bar }}}}} + other {foo is {{ foo }}} + } + `, +}) +export class MyComp { + @Input() foo = 0; + @Input() bar = 0; +} diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/template_ng_let.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/template_ng_let.ts new file mode 100644 index 000000000000..3e79990b4107 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/template_ng_let.ts @@ -0,0 +1,16 @@ +// tslint:disable + +import {Component, Input} from '@angular/core'; + +@Component({ + template: ` + @let sum = one + two; + @let three = this.three; + {{ sum }} {{ three }} + `, +}) +export class MyComp { + @Input() one = 1; + @Input() two = 2; + @Input() three = 3; +} diff --git a/packages/core/schematics/migrations/signal-migration/test/golden.txt b/packages/core/schematics/migrations/signal-migration/test/golden.txt index c32b912a120b..4c94eeb8b312 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden.txt @@ -1212,6 +1212,24 @@ import {Component, input} from '@angular/core'; export class WithConcatTemplate { readonly bla = input(true); } +@@@@@@ template_icu.ts @@@@@@ + +// tslint:disable + +import {Component, input} from '@angular/core'; + +@Component({ + template: ` + {foo(), plural, + =0 {{bar(), plural, =0 {zero} other {zero, bar is {{ bar() }}}}} + other {foo is {{ foo() }}} + } + `, +}) +export class MyComp { + readonly foo = input(0); + readonly bar = input(0); +} @@@@@@ template_ng_if.ts @@@@@@ // tslint:disable @@ -1257,6 +1275,24 @@ export class MyComp { readonly fourth = input(true); readonly fifth = input(true); } +@@@@@@ template_ng_let.ts @@@@@@ + +// tslint:disable + +import {Component, input} from '@angular/core'; + +@Component({ + template: ` + @let sum = one() + two(); + @let three = this.three(); + {{ sum }} {{ three }} + `, +}) +export class MyComp { + readonly one = input(1); + readonly two = input(2); + readonly three = input(3); +} @@@@@@ template_object_shorthand.ts @@@@@@ // tslint:disable diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index eb04d2ab3255..c2e683b87395 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -1175,6 +1175,24 @@ import {Component, input} from '@angular/core'; export class WithConcatTemplate { readonly bla = input(true); } +@@@@@@ template_icu.ts @@@@@@ + +// tslint:disable + +import {Component, input} from '@angular/core'; + +@Component({ + template: ` + {foo(), plural, + =0 {{bar(), plural, =0 {zero} other {zero, bar is {{ bar() }}}}} + other {foo is {{ foo() }}} + } + `, +}) +export class MyComp { + readonly foo = input(0); + readonly bar = input(0); +} @@@@@@ template_ng_if.ts @@@@@@ // tslint:disable @@ -1211,6 +1229,24 @@ export class MyComp { readonly fourth = input(true); readonly fifth = input(true); } +@@@@@@ template_ng_let.ts @@@@@@ + +// tslint:disable + +import {Component, input} from '@angular/core'; + +@Component({ + template: ` + @let sum = one() + two(); + @let three = this.three(); + {{ sum }} {{ three }} + `, +}) +export class MyComp { + readonly one = input(1); + readonly two = input(2); + readonly three = input(3); +} @@@@@@ template_object_shorthand.ts @@@@@@ // tslint:disable diff --git a/packages/core/schematics/ng-generate/inject-migration/index.ts b/packages/core/schematics/ng-generate/inject-migration/index.ts index 9c13384b79af..81d35b36f922 100644 --- a/packages/core/schematics/ng-generate/inject-migration/index.ts +++ b/packages/core/schematics/ng-generate/inject-migration/index.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; import {join, relative} from 'path'; +import ts from 'typescript'; import {normalizePath} from '../../utils/change_tracker'; import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; @@ -21,49 +22,54 @@ interface Options extends MigrationOptions { } export function migrate(options: Options): Rule { - return async (tree: Tree) => { - const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + return async (tree: Tree, context: SchematicContext) => { const basePath = process.cwd(); + let pathToMigrate: string | undefined; + if (options.path) { + if (options.path.startsWith('..')) { + throw new SchematicsException( + 'Cannot run inject migration outside of the current project.', + ); + } + pathToMigrate = normalizePath(join(basePath, options.path)); + } + + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); const allPaths = [...buildPaths, ...testPaths]; - const pathToMigrate = normalizePath(join(basePath, options.path)); if (!allPaths.length) { - throw new SchematicsException( - 'Could not find any tsconfig file. Cannot run the inject migration.', - ); + context.logger.warn('Could not find any tsconfig file. Cannot run the inject migration.'); + return; } + let sourceFilesCount = 0; + for (const tsconfigPath of allPaths) { - runInjectMigration(tree, tsconfigPath, basePath, pathToMigrate, options); + const program = createMigrationProgram(tree, tsconfigPath, basePath); + const sourceFiles = program + .getSourceFiles() + .filter( + (sourceFile) => + (pathToMigrate ? sourceFile.fileName.startsWith(pathToMigrate) : true) && + canMigrateFile(basePath, sourceFile, program), + ); + + sourceFilesCount += runInjectMigration(tree, sourceFiles, basePath, options); + } + + if (sourceFilesCount === 0) { + context.logger.warn('Inject migration did not find any files to migrate'); } }; } function runInjectMigration( tree: Tree, - tsconfigPath: string, + sourceFiles: ts.SourceFile[], basePath: string, - pathToMigrate: string, schematicOptions: Options, -): void { - if (schematicOptions.path.startsWith('..')) { - throw new SchematicsException('Cannot run inject migration outside of the current project.'); - } - - const program = createMigrationProgram(tree, tsconfigPath, basePath); - const sourceFiles = program - .getSourceFiles() - .filter( - (sourceFile) => - sourceFile.fileName.startsWith(pathToMigrate) && - canMigrateFile(basePath, sourceFile, program), - ); - - if (sourceFiles.length === 0) { - throw new SchematicsException( - `Could not find any files to migrate under the path ${pathToMigrate}. Cannot run the inject migration.`, - ); - } +): number { + let migratedFiles = 0; for (const sourceFile of sourceFiles) { const changes = migrateFile(sourceFile, schematicOptions); @@ -79,6 +85,8 @@ function runInjectMigration( } tree.commitUpdate(update); + migratedFiles++; } } + return migratedFiles; } diff --git a/packages/core/schematics/ng-generate/route-lazy-loading/to-lazy-routes.ts b/packages/core/schematics/ng-generate/route-lazy-loading/to-lazy-routes.ts index 0f8a8fd93c56..b1d839d23206 100644 --- a/packages/core/schematics/ng-generate/route-lazy-loading/to-lazy-routes.ts +++ b/packages/core/schematics/ng-generate/route-lazy-loading/to-lazy-routes.ts @@ -280,9 +280,26 @@ function migrateRoute( return routeMigrationResults; } - const componentImport = route.routeFileImports.find((importDecl) => - importDecl.importClause?.getText().includes(componentClassName), - )!; + // Resolve the import that provides this component by exact specifier match + // Handles default imports, named imports, and aliases (e.g., `import { Foo as Bar }`). + const componentImport = route.routeFileImports.find((importDecl) => { + const clause = importDecl.importClause; + if (!clause) return false; + // Default import: import FooComponent from '...' + if (clause.name && ts.isIdentifier(clause.name) && clause.name.text === componentClassName) { + return true; + } + // Named imports: import { FooComponent } from '...' + const named = clause.namedBindings; + if (named && ts.isNamedImports(named)) { + return named.elements.some((el: ts.ImportSpecifier) => { + // Support alias: import { Foo as Bar } + const importedName = el.propertyName ? el.propertyName.text : el.name.text; + return importedName === componentClassName; + }); + } + return false; + })!; // remove single and double quotes from the import path let componentImportPath = ts.isStringLiteral(componentImport?.moduleSpecifier) diff --git a/packages/core/schematics/ng-generate/standalone-migration/prune-modules.ts b/packages/core/schematics/ng-generate/standalone-migration/prune-modules.ts index 311b976ca208..462385369ebf 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/prune-modules.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/prune-modules.ts @@ -117,6 +117,7 @@ export function pruneNgModules( tracker, typeChecker, templateTypeChecker, + tsProgram, declarationImportRemapper, ); @@ -271,6 +272,7 @@ function replaceInComponentImportsArray( tracker: ChangeTracker, typeChecker: ts.TypeChecker, templateTypeChecker: TemplateTypeChecker, + program: ts.Program, importRemapper?: DeclarationImportsRemapper, ) { for (const [array, toReplace] of componentImportArrays.getEntries()) { @@ -282,7 +284,7 @@ function replaceInComponentImportsArray( const replacements = new UniqueItemTracker>(); const usedImports = new Set( - findTemplateDependencies(closestClass, templateTypeChecker).map((ref) => ref.node), + findTemplateDependencies(closestClass, templateTypeChecker, program).map((ref) => ref.node), ); const nodesToRemove = new Set(); diff --git a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts index 0700cbdc5c1d..00596aee9722 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts @@ -121,6 +121,7 @@ export function toStandaloneBootstrap( allDeclarations, tracker, templateTypeChecker, + program.getTsProgram(), declarationImportRemapper, ); } diff --git a/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts b/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts index 3af575d32f2f..2c5e8c1ef4f0 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts @@ -20,7 +20,6 @@ import {ChangesByFile, ChangeTracker, ImportRemapper} from '../../utils/change_t import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators'; import {getImportSpecifier} from '../../utils/typescript/imports'; import {closestNode} from '../../utils/typescript/nodes'; -import {isReferenceToImport} from '../../utils/typescript/symbol'; import { findClassDeclaration, @@ -88,6 +87,7 @@ export function toStandalone( declarations, tracker, templateTypeChecker, + program.getTsProgram(), declarationImportRemapper, ); } @@ -119,6 +119,7 @@ export function convertNgModuleDeclarationToStandalone( allDeclarations: Set, tracker: ChangeTracker, typeChecker: TemplateTypeChecker, + program: ts.Program, importRemapper?: DeclarationImportsRemapper, ): void { const directiveMeta = typeChecker.getDirectiveMetadata(decl); @@ -132,6 +133,7 @@ export function convertNgModuleDeclarationToStandalone( allDeclarations, tracker, typeChecker, + program, importRemapper, ); @@ -175,9 +177,10 @@ function getComponentImportExpressions( allDeclarations: Set, tracker: ChangeTracker, typeChecker: TemplateTypeChecker, + program: ts.Program, importRemapper?: DeclarationImportsRemapper, ): ts.Expression[] { - const templateDependencies = findTemplateDependencies(decl, typeChecker); + const templateDependencies = findTemplateDependencies(decl, typeChecker, program); const usedDependenciesInMigration = new Set( templateDependencies.filter((dep) => allDeclarations.has(dep.node)), ); @@ -656,6 +659,7 @@ export function findTestObjectsToMigrate(sourceFile: ts.SourceFile, typeChecker: export function findTemplateDependencies( decl: ts.ClassDeclaration, typeChecker: TemplateTypeChecker, + program: ts.Program, ): Reference[] { const results: Reference[] = []; const usedDirectives = typeChecker.getUsedDirectives(decl); @@ -663,9 +667,7 @@ export function findTemplateDependencies( if (usedDirectives !== null) { for (const dir of usedDirectives) { - if (ts.isClassDeclaration(dir.ref.node)) { - results.push(dir.ref as Reference); - } + results.push(dir.ref as Reference); } } @@ -673,11 +675,17 @@ export function findTemplateDependencies( const potentialPipes = typeChecker.getPotentialPipes(decl); for (const pipe of potentialPipes) { - if ( - ts.isClassDeclaration(pipe.ref.node) && - usedPipes.some((current) => pipe.name === current) - ) { - results.push(pipe.ref as Reference); + const sourceFile = program.getSourceFile(pipe.ref.filePath); + const node = sourceFile ? findTightestNode(sourceFile, pipe.ref.position) : null; + const classDecl = node ? closestNode(node, ts.isClassDeclaration) : null; + if (classDecl && usedPipes.some((current) => pipe.name === current)) { + const owningModule = pipe.ref.moduleSpecifier + ? { + specifier: pipe.ref.moduleSpecifier, + resolutionContext: decl.getSourceFile().fileName, + } + : null; + results.push(new Reference(classDecl as NamedClassDeclaration, owningModule)); } } } @@ -947,3 +955,10 @@ function isStandaloneDeclaration( templateTypeChecker.getDirectiveMetadata(node) || templateTypeChecker.getPipeMetadata(node); return metadata != null && metadata.isStandalone; } + +function findTightestNode(node: ts.Node, position: number): ts.Node | undefined { + if (position < node.getStart() || position > node.getEnd()) { + return undefined; + } + return node.forEachChild((c) => findTightestNode(c, position)) ?? node; +} diff --git a/packages/core/schematics/test/inject_migration_spec.ts b/packages/core/schematics/test/inject_migration_spec.ts index d0ece6c09d9f..342cf21d560e 100644 --- a/packages/core/schematics/test/inject_migration_spec.ts +++ b/packages/core/schematics/test/inject_migration_spec.ts @@ -384,6 +384,76 @@ describe('inject migration', () => { ]); }); + it('should migrate files present in other workspace projects', async () => { + writeFile('/tsconfig.json', '{}'); + + // Multiple projects... + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: { + app: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}, + lib: {root: 'lib', architect: {build: {options: {tsConfig: './lib/tsconfig.json'}}}}, + }, + }), + ); + + // The lib tsconfig includes only its own folder so the second program does see the file. + writeFile('/lib/tsconfig.json', JSON.stringify({include: ['**/*.ts']})); + + // File that should be migrated exists only under the second project's folder. + writeFile( + '/lib/should-migrate/dir.ts', + [ + `import { Directive } from '@angular/core';`, + `import { Foo } from 'foo';`, + ``, + `@Directive()`, + `class MyDir {`, + ` constructor(private foo: Foo) {}`, + `}`, + ].join('\n'), + ); + + // Unrelated file outside the specified path should remain unchanged. + writeFile( + '/other.ts', + [ + `import { Directive } from '@angular/core';`, + `import { Foo } from 'foo';`, + ``, + `@Directive()`, + `class Other {`, + ` constructor(private foo: Foo) {}`, + `}`, + ].join('\n'), + ); + + // Files should be migrated under the path + await runMigration({path: 'lib/should-migrate'}); + + expect(tree.readContent('/lib/should-migrate/dir.ts').split('\n')).toEqual([ + `import { Directive, inject } from '@angular/core';`, + `import { Foo } from 'foo';`, + ``, + `@Directive()`, + `class MyDir {`, + ` private foo = inject(Foo);`, + `}`, + ]); + + expect(tree.readContent('/other.ts').split('\n')).toEqual([ + `import { Directive } from '@angular/core';`, + `import { Foo } from 'foo';`, + ``, + `@Directive()`, + `class Other {`, + ` constructor(private foo: Foo) {}`, + `}`, + ]); + }); + it('should only migrate the specified file', async () => { writeFile( '/dir.ts', diff --git a/packages/core/schematics/test/ngclass_to_class_migration_spec.ts b/packages/core/schematics/test/ngclass_to_class_migration_spec.ts index b5012497f6ce..c538d2189807 100644 --- a/packages/core/schematics/test/ngclass_to_class_migration_spec.ts +++ b/packages/core/schematics/test/ngclass_to_class_migration_spec.ts @@ -519,6 +519,31 @@ describe('NgClass migration', () => { expect(content).not.toContain('imports: [NgFor, NgIf,]'); // No trailing comma }); + it('should handle multiline imports array formatting with NgClass at the end', async () => { + writeFile( + '/app.component.ts', + ` + import {Component} from '@angular/core'; + import {NgClass} from '@angular/common'; + + @Component({ + template: \`
\`, + imports: [NgClass], + }) + export class Cmp {} + `, + ); + + await runMigration(); + + const content = tree.readContent('/app.component.ts'); + + expect(content).toContain(`@Component({ + template: \`
\`, + }) + export class Cmp {}`); + }); + it('should handle multiline imports array formatting', async () => { writeFile( '/app.component.ts', diff --git a/packages/core/schematics/test/ngstyle_to_style_migration_spec.ts b/packages/core/schematics/test/ngstyle_to_style_migration_spec.ts index bf43d0b2246f..bd888f55bc3e 100644 --- a/packages/core/schematics/test/ngstyle_to_style_migration_spec.ts +++ b/packages/core/schematics/test/ngstyle_to_style_migration_spec.ts @@ -384,6 +384,31 @@ describe('NgStyle migration', () => { export class Cmp {}`); }); + it('should handle multiline imports array formatting with NgStyle at the end', async () => { + writeFile( + '/app.component.ts', + ` + import {Component} from '@angular/core'; + import {NgStyle} from '@angular/common'; + + @Component({ + template: \`
\`, + imports: [NgStyle], + }) + export class Cmp {} + `, + ); + + await runMigration(); + + const content = tree.readContent('/app.component.ts'); + + expect(content).toContain(`@Component({ + template: \`
\`, + }) + export class Cmp {}`); + }); + it('should migrate when NgStyle is provided by CommonModule', async () => { writeFile( '/app.component.ts', diff --git a/packages/core/schematics/test/standalone_routes_spec.ts b/packages/core/schematics/test/standalone_routes_spec.ts index 50bd2808aeba..d1e6ae25561f 100644 --- a/packages/core/schematics/test/standalone_routes_spec.ts +++ b/packages/core/schematics/test/standalone_routes_spec.ts @@ -893,6 +893,59 @@ describe('route lazy loading migration', () => { ); }); + it('should resolve the correct import when one component name is a suffix of another', async () => { + writeFile( + 'app.module.ts', + ` + import {NgModule} from '@angular/core'; + import {RouterModule} from '@angular/router'; + import {FooBarComponent} from './foo-bar'; + import {BarComponent} from './bar'; + + @NgModule({ + imports: [RouterModule.forRoot([ + {path: 'foo-bar', component: FooBarComponent}, + {path: 'bar', component: BarComponent}, + ])], + }) + export class AppModule {} + `, + ); + + writeFile( + 'foo-bar.ts', + ` + import {Component} from '@angular/core'; + @Component({template: 'foo bar', standalone: true}) + export class FooBarComponent {} + `, + ); + + writeFile( + 'bar.ts', + ` + import {Component} from '@angular/core'; + @Component({template: 'bar', standalone: true}) + export class BarComponent {} + `, + ); + + await runMigration('route-lazy-loading'); + + const result = stripWhitespace(tree.readContent('app.module.ts')); + + expect(result).toContain( + stripWhitespace( + `{path: 'foo-bar', loadComponent: () => import('./foo-bar').then(m => m.FooBarComponent)}`, + ), + ); + expect(result).toContain( + stripWhitespace( + `{path: 'bar', loadComponent: () => import('./bar').then(m => m.BarComponent)}`, + ), + ); + }); + // TODO: support multiple imports of components // ex import * as Components from './components'; // export const MenuRoutes: Routes = [ diff --git a/packages/core/src/animation/interfaces.ts b/packages/core/src/animation/interfaces.ts index baab333bf72c..179cf0c83af7 100644 --- a/packages/core/src/animation/interfaces.ts +++ b/packages/core/src/animation/interfaces.ts @@ -50,6 +50,8 @@ const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000; * function callbacks. * * @publicApi 20.2 + * + * @see [Animating your applications with animate.enter and animate.leave](guide/animations) */ export type AnimationFunction = (event: AnimationCallbackEvent) => void; diff --git a/packages/core/src/animation/longest_animation.ts b/packages/core/src/animation/longest_animation.ts index 2ec1a520528f..d2f4c727a13e 100644 --- a/packages/core/src/animation/longest_animation.ts +++ b/packages/core/src/animation/longest_animation.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import {LView} from '../render3/interfaces/view'; import {LongestAnimation} from './interfaces'; /** Parses a CSS time value to milliseconds. */ @@ -43,10 +42,12 @@ function getLongestComputedAnimation(computedStyle: CSSStyleDeclaration): Longes const rawNames = parseCssPropertyValue(computedStyle, 'animation-name'); const rawDelays = parseCssPropertyValue(computedStyle, 'animation-delay'); const rawDurations = parseCssPropertyValue(computedStyle, 'animation-duration'); + const rawIterationCounts = parseCssPropertyValue(computedStyle, 'animation-iteration-count'); const longest: LongestAnimation = {animationName: '', propertyName: undefined, duration: 0}; for (let i = 0; i < rawNames.length; i++) { const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]); - if (duration > longest.duration) { + const iterationCount = rawIterationCounts[i]; + if (duration > longest.duration && iterationCount !== 'infinite') { longest.animationName = rawNames[i]; longest.duration = duration; } @@ -123,10 +124,19 @@ function determineLongestAnimationFromElementAnimations( }; for (const animation of animations) { const timing = animation.effect?.getTiming(); + if (timing?.iterations === Infinity) { + continue; + } // duration can be a string 'auto' or a number. const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0; let duration = (timing?.delay ?? 0) + animDuration; + // Account for playback rate if it is set + const playbackRate = animation.playbackRate; + if (playbackRate !== undefined && playbackRate !== 0 && playbackRate !== 1) { + duration /= Math.abs(playbackRate); + } + let propertyName: string | undefined; let animationName: string | undefined; diff --git a/packages/core/src/animation/queue.ts b/packages/core/src/animation/queue.ts index 86e4942ebe66..86b27ec6db9a 100644 --- a/packages/core/src/animation/queue.ts +++ b/packages/core/src/animation/queue.ts @@ -24,14 +24,11 @@ export const ANIMATION_QUEUE = new InjectionToken( typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '', { factory: () => { - const injector = inject(EnvironmentInjector); - const queue = new Set(); - injector.onDestroy(() => queue.clear()); return { - queue, + queue: new Set(), isScheduled: false, scheduler: null, - injector, + injector: inject(EnvironmentInjector), // should be the root injector }; }, }, diff --git a/packages/core/src/animation/utils.ts b/packages/core/src/animation/utils.ts index e919509f516c..3369b6255c7d 100644 --- a/packages/core/src/animation/utils.ts +++ b/packages/core/src/animation/utils.ts @@ -115,6 +115,10 @@ export const longestAnimations = new WeakMap(); // from an `@if` or `@for`. export const leavingNodes = new WeakMap(); +// Tracks nodes that have scheduled leave animations but were re-inserted into the DOM +// before the animation completed, thus rescuing them from being physically removed. +export const reusedNodes = new WeakSet(); + /** * This actually removes the leaving HTML Element in the TNode */ @@ -153,11 +157,14 @@ export function cancelLeavingNodes(tNode: TNode, newElement: HTMLElement): void // because Angular inserts new elements at the same position (before // the container anchor) where the leaving element was, making them // always adjacent. Covers @if toggling and same-VCR toggling. - // - In a different DOM parent (overlay/portal case where each instance - // renders in its own container, e.g. CDK Overlay). - // Leaving elements in the same parent that are NOT the previousSibling - // are left alone (e.g. @for items animating out at different positions). - if ( + // - The leaving element IS the new element. This happens when a node is moved + // (e.g., drag-and-drop reordering). We must cancel its pending leave animation + // and ensure it's not physically removed from the DOM by marking it as reused. + if (leavingEl === newElement) { + nodes.splice(i, 1); + reusedNodes.add(leavingEl); + leavingEl.dispatchEvent(new CustomEvent('animationend', {detail: {cancel: true}})); + } else if ( (prevSibling && leavingEl === prevSibling) || (leavingParent && newParent && leavingParent !== newParent) ) { @@ -248,6 +255,13 @@ export function elementHasClassList(element: HTMLElement, classList: string[]): return false; } +/** Gets the target of an event while accounting for Shadow DOM. */ +export function getEventTarget(event: Event): T | null { + // If an event is bound outside the Shadow DOM, the `event.target` will + // point to the shadow root so we have to use `composedPath` instead. + return (event.composedPath ? event.composedPath()[0] : event.target) as T | null; +} + /** * Determines if the animation or transition event is currently the expected longest animation * based on earlier determined data in `longestAnimations` @@ -265,11 +279,12 @@ export function isLongestAnimation( // block the animationend/transitionend event from doing its work. if (longestAnimation === undefined) return true; return ( - nativeElement === event.target && + nativeElement === getEventTarget(event) && ((longestAnimation.animationName !== undefined && (event as AnimationEvent).animationName === longestAnimation.animationName) || (longestAnimation.propertyName !== undefined && - (event as TransitionEvent).propertyName === longestAnimation.propertyName)) + (longestAnimation.propertyName === 'all' || + (event as TransitionEvent).propertyName === longestAnimation.propertyName))) ); } diff --git a/packages/core/src/application/tracing.ts b/packages/core/src/application/tracing.ts index 1e25341b18e6..48a315054e9d 100644 --- a/packages/core/src/application/tracing.ts +++ b/packages/core/src/application/tracing.ts @@ -64,4 +64,11 @@ export interface TracingService { * @return A new event handler to be bound instead of the original one. */ wrapEventListener?(element: HTMLElement, eventName: string, handler: T): T; + + /** + * Trace the creation of a component instance. + * @param className Name of the component. May be null if the class is anonymous. + * @param fn Function that creates the component instance. + */ + componentCreate?(className: string | null, fn: () => T): T; } diff --git a/packages/core/src/authoring/input/input.ts b/packages/core/src/authoring/input/input.ts index 9d0a38136f05..dcc4394f8a89 100644 --- a/packages/core/src/authoring/input/input.ts +++ b/packages/core/src/authoring/input/input.ts @@ -77,6 +77,19 @@ export interface InputFunction { initialValue: undefined, opts: InputOptionsWithTransform, ): InputSignalWithTransform; + /** + * Declares an input of type `T` with an initial value and a transform function + * that accepts values of the same type. + */ + (initialValue: T, opts: InputOptionsWithTransform): InputSignalWithTransform; + /** + * Declares an input of type `T|undefined` without an initial value and with a transform + * function that accepts values of the same type. + */ + ( + initialValue: undefined, + opts: InputOptionsWithTransform, + ): InputSignalWithTransform; /** * Initializes a required input. @@ -149,6 +162,8 @@ export interface InputFunction { * * @publicAPI * @initializerApiFunction + * + * @see [Accepting data with input properties](guide/components/inputs) */ export const input: InputFunction = (() => { // Note: This may be considered a side-effect, but nothing will depend on diff --git a/packages/core/src/authoring/input/input_signal_node.ts b/packages/core/src/authoring/input/input_signal_node.ts index b521545b1a3e..d57dedafb554 100644 --- a/packages/core/src/authoring/input/input_signal_node.ts +++ b/packages/core/src/authoring/input/input_signal_node.ts @@ -39,7 +39,6 @@ export interface InputSignalNode extends SignalNode { // Note: Using an IIFE here to ensure that the spread assignment is not considered // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. export const INPUT_SIGNAL_NODE: InputSignalNode = /* @__PURE__ */ (() => { return { ...SIGNAL_NODE, diff --git a/packages/core/src/authoring/queries.ts b/packages/core/src/authoring/queries.ts index 8b5393bc46c6..a19defc80711 100644 --- a/packages/core/src/authoring/queries.ts +++ b/packages/core/src/authoring/queries.ts @@ -97,7 +97,7 @@ export interface ViewChildFunction { * * ```angular-ts * @Component({template: '
'}) - * export class TestComponent { + * export class Card { * divEl = viewChild('el'); // Signal * divElRequired = viewChild.required('el'); // Signal * cmp = viewChild(MyComponent); // Signal @@ -255,7 +255,7 @@ export interface ContentChildFunction { * * ```ts * @Component({...}) - * export class TestComponent { + * export class Card { * headerEl = contentChild('h'); // Signal * headerElElRequired = contentChild.required('h'); // Signal * header = contentChild(MyHeader); // Signal @@ -263,10 +263,12 @@ export interface ContentChildFunction { * } * ``` * - * Note: By default `descendants` is `true` which means the query will traverse all descendants in the same template. + * NOTE: By default `descendants` is `true` which means the query will traverse all descendants in the same template. * * @initializerApiFunction * @publicApi 19.0 + * + * @See [Content queries](guide/components/queries#content-queries) */ export const contentChild: ContentChildFunction = (() => { // Note: This may be considered a side-effect, but nothing will depend on diff --git a/packages/core/src/change_detection/differs/keyvalue_differs.ts b/packages/core/src/change_detection/differs/keyvalue_differs.ts index 1813908fcc6a..c3eeb73607b7 100644 --- a/packages/core/src/change_detection/differs/keyvalue_differs.ts +++ b/packages/core/src/change_detection/differs/keyvalue_differs.ts @@ -133,7 +133,7 @@ export class KeyValueDiffers { this.factories = factories; } - static create(factories: KeyValueDifferFactory[], parent?: KeyValueDiffers): KeyValueDiffers { + static create(factories: KeyValueDifferFactory[], parent?: KeyValueDiffers): KeyValueDiffers { if (parent) { const copied = parent.factories.slice(); factories = factories.concat(copied); @@ -161,7 +161,7 @@ export class KeyValueDiffers { * }) * ``` */ - static extend(factories: KeyValueDifferFactory[]): StaticProvider { + static extend(factories: KeyValueDifferFactory[]): StaticProvider { return { provide: KeyValueDiffers, useFactory: () => { diff --git a/packages/core/src/change_detection/lifecycle_hooks.ts b/packages/core/src/change_detection/lifecycle_hooks.ts index 4a61dc26e8e6..d0f15e9509eb 100644 --- a/packages/core/src/change_detection/lifecycle_hooks.ts +++ b/packages/core/src/change_detection/lifecycle_hooks.ts @@ -76,14 +76,19 @@ export interface OnInit { /** * A lifecycle hook that invokes a custom change-detection function for a directive, - * in addition to the check performed by the default change-detector. + * in addition to the check performed by the default change-detector on the input + * bindings for this directive usage in the parent template. Note that this hook is + * invoked even when the directive's own change detection is skipped (e.g., with + * the `OnPush` change detection strategy). Developers might use this hook to + * implement a custom change detection strategy for some of the inputs. * * The default change-detection algorithm looks for differences by comparing * bound-property values by reference across change detection runs. You can use this * hook to check for and respond to changes by some other means. * - * When the default change detector detects changes, it invokes `ngOnChanges()` if supplied, - * regardless of whether you perform additional change detection. + * When the default change detector detects changes to the directive's input bindings, + * it invokes `ngOnChanges()` if supplied, regardless of whether you perform + * additional change detection. * Typically, you should not use both `DoCheck` and `OnChanges` to respond to * changes on the same input. * @@ -104,7 +109,8 @@ export interface OnInit { export interface DoCheck { /** * A callback method that performs change-detection, invoked - * after the default change-detector runs. + * after the default change-detector has checked the directive's input + * bindings in the parent template. * See `KeyValueDiffers` and `IterableDiffers` for implementing * custom change checking for collections. * diff --git a/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts index f714e5fb6682..deb28cbfa6e8 100644 --- a/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts @@ -24,14 +24,14 @@ import {performanceMarkFeature} from '../../util/performance'; import {NgZone} from '../../zone'; import {InternalNgZoneOptions} from '../../zone/ng_zone'; +import {INTERNAL_APPLICATION_ERROR_HANDLER} from '../../error_handler'; +import {OnDestroy} from '../lifecycle_hooks'; +import {SCHEDULE_IN_ROOT_ZONE_DEFAULT} from './flags'; import { ChangeDetectionScheduler, - ZONELESS_ENABLED, SCHEDULE_IN_ROOT_ZONE, + ZONELESS_ENABLED, } from './zoneless_scheduling'; -import {SCHEDULE_IN_ROOT_ZONE_DEFAULT} from './flags'; -import {INTERNAL_APPLICATION_ERROR_HANDLER} from '../../error_handler'; -import {OnDestroy} from '../lifecycle_hooks'; @Injectable({providedIn: 'root'}) export class NgZoneChangeDetectionScheduler implements OnDestroy { @@ -134,10 +134,10 @@ export function internalProvideZoneChangeDetection({ * Provides `NgZone`-based change detection for the application bootstrapped using * `bootstrapApplication`. * - * `NgZone` is already provided in applications by default. This provider allows you to configure - * options like `eventCoalescing` in the `NgZone`. - * This provider is not available for `platformBrowser().bootstrapModule`, which uses - * `BootstrapOptions` instead. + * Add this provider to use `NgZone`/ZoneJS-based change detection and configure options like + * `eventCoalescing` in the `NgZone`. + * + * If you need this provider function in an NgModule-based application, pass it as `applicationProviders` to `bootstrapModule()`. * * @usageNotes * ```ts diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index a12314635399..9c6cbb2afecb 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -95,7 +95,7 @@ export * from './core_private_export'; export * from './core_render3_private_export'; export * from './core_reactivity_export'; export * from './resource'; -export {SecurityContext} from './sanitization/security'; +export {SecurityContext} from './sanitization/dom_security_schema'; export {Sanitizer} from './sanitization/sanitizer'; export { createNgModule, diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index d1b870cb07c7..8c433c4ceb89 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -18,7 +18,6 @@ export { export {compileNgModuleFactory as ɵcompileNgModuleFactory} from './application/application_ngmodule_factory_compiler'; export {isBoundToModule as ɵisBoundToModule} from './application/application_ref'; export {injectChangeDetectorRef as ɵinjectChangeDetectorRef} from './change_detection/change_detector_ref'; -export {getDebugNode as ɵgetDebugNode} from './debug/debug_node'; export {createInjector as ɵcreateInjector} from './di/create_injector'; export { isInjectable as ɵisInjectable, diff --git a/packages/core/src/defer/interfaces.ts b/packages/core/src/defer/interfaces.ts index fc180333a3e8..4f9628644be9 100644 --- a/packages/core/src/defer/interfaces.ts +++ b/packages/core/src/defer/interfaces.ts @@ -1,4 +1,4 @@ -/** +/** * @license * Copyright Google LLC All Rights Reserved. * @@ -264,7 +264,7 @@ export interface LDeferBlockDetails extends Array { /** * Timestamp indicating when the current state can be switched to - * the next one, in case teh current state has `minimum` parameter. + * the next one, in case the current state has `minimum` parameter. */ [STATE_IS_FROZEN_UNTIL]: number | null; diff --git a/packages/core/src/defer/triggering.ts b/packages/core/src/defer/triggering.ts index 8a11d2d7d892..a1b865dea51b 100644 --- a/packages/core/src/defer/triggering.ts +++ b/packages/core/src/defer/triggering.ts @@ -222,10 +222,12 @@ export function triggerResourceLoading( // Start downloading of defer block dependencies. tDetails.loadingPromise = Promise.allSettled(dependenciesFn()).then((results) => { let failed = false; + let failedReason: Error | null = null; const directiveDefs: DirectiveDefList = []; const pipeDefs: PipeDefList = []; - for (const result of results) { + for (let i = 0; i < results.length; i++) { + const result = results[i]; if (result.status === 'fulfilled') { const dependency = result.value; const directiveDef = getComponentDef(dependency) || getDirectiveDef(dependency); @@ -239,6 +241,8 @@ export function triggerResourceLoading( } } else { failed = true; + failedReason = + result.reason instanceof Error ? result.reason : new Error(String(result.reason)); break; } } @@ -248,13 +252,31 @@ export function triggerResourceLoading( if (tDetails.errorTmplIndex === null) { const templateLocation = ngDevMode ? getTemplateLocationDetails(lView) : ''; - const error = new RuntimeError( - RuntimeErrorCode.DEFER_LOADING_FAILED, - ngDevMode && + let errorMsg = ''; + + if (ngDevMode) { + errorMsg = 'Loading dependencies for `@defer` block failed, ' + - `but no \`@error\` block was configured${templateLocation}. ` + - 'Consider using the `@error` block to render an error state.', - ); + `but no \`@error\` block was configured${templateLocation}. ` + + 'Consider using the `@error` block to render an error state.'; + + const depsFn = tDetails.dependencyResolverFn; + const errorReason = failedReason?.message; + + if (depsFn) { + errorMsg += + `\n\nAngular tried to invoke the following dependency function (compiler-generated):\n` + + `\`\`\`\n${depsFn.toString()}\n\`\`\``; + } + + if (errorReason) { + errorMsg += depsFn + ? `\n\nbut it resulted in the following error:\n\n${errorReason}` + : `\n\nThe loading resulted in the following error:\n\n${errorReason}`; + } + } + + const error = new RuntimeError(RuntimeErrorCode.DEFER_LOADING_FAILED, errorMsg); handleUncaughtError(lView, error); } } else { diff --git a/packages/core/src/di/create_injector.ts b/packages/core/src/di/create_injector.ts index 61a7723b0190..a11b84756365 100644 --- a/packages/core/src/di/create_injector.ts +++ b/packages/core/src/di/create_injector.ts @@ -47,7 +47,10 @@ export function createInjectorWithoutInjectorInstances( scopes = new Set(), ): R3Injector { const providers = [additionalProviders || EMPTY_ARRAY, importProvidersFrom(defType)]; - name = name || (typeof defType === 'object' ? undefined : stringify(defType)); + let source: string | undefined = undefined; + if (ngDevMode) { + source = name || (typeof defType === 'object' ? undefined : stringify(defType)); + } - return new R3Injector(providers, parent || getNullInjector(), name || null, scopes); + return new R3Injector(providers, parent || getNullInjector(), source || null, scopes); } diff --git a/packages/core/src/di/forward_ref.ts b/packages/core/src/di/forward_ref.ts index d91856e4ad31..5a5c5ee25d2e 100644 --- a/packages/core/src/di/forward_ref.ts +++ b/packages/core/src/di/forward_ref.ts @@ -68,9 +68,12 @@ const __forward_ref__ = getClosureSafeProperty({__forward_ref__: getClosureSafeP */ export function forwardRef(forwardRefFn: ForwardRefFn): Type { (forwardRefFn).__forward_ref__ = forwardRef; - (forwardRefFn).toString = function () { - return stringify(this()); - }; + if (ngDevMode) { + (forwardRefFn).toString = function () { + return stringify(this()); + }; + } + return >(forwardRefFn); } diff --git a/packages/core/src/di/interface/defs.ts b/packages/core/src/di/interface/defs.ts index 796fccbc3520..8681668ed12c 100644 --- a/packages/core/src/di/interface/defs.ts +++ b/packages/core/src/di/interface/defs.ts @@ -167,14 +167,14 @@ export interface InjectorTypeWithProviders { export function ɵɵdefineInjectable(opts: { token: unknown; providedIn?: Type | 'root' | 'platform' | 'any' | 'environment' | null; - factory: () => T; -}): unknown { + factory: (parent?: Type) => T; +}): ɵɵInjectableDeclaration { return { token: opts.token, providedIn: (opts.providedIn as any) || null, factory: opts.factory, value: undefined, - } as ɵɵInjectableDeclaration; + }; } /** diff --git a/packages/core/src/di/r3_injector.ts b/packages/core/src/di/r3_injector.ts index b06cc0404c23..bd1ea0c5a5f6 100644 --- a/packages/core/src/di/r3_injector.ts +++ b/packages/core/src/di/r3_injector.ts @@ -455,12 +455,16 @@ export class R3Injector extends EnvironmentInjector implements PrimitivesInjecto } override toString() { - const tokens: string[] = []; - const records = this.records; - for (const token of records.keys()) { - tokens.push(stringify(token)); + if (ngDevMode) { + const tokens: string[] = []; + const records = this.records; + for (const token of records.keys()) { + tokens.push(stringify(token)); + } + return `R3Injector[${tokens.join(', ')}]`; } - return `R3Injector[${tokens.join(', ')}]`; + + return 'R3Injector[...]'; } /** @@ -521,7 +525,7 @@ export class R3Injector extends EnvironmentInjector implements PrimitivesInjecto const prevConsumer = setActiveConsumer(null); try { if (record.value === CIRCULAR) { - throw cyclicDependencyError(stringify(token)); + throw cyclicDependencyError(ngDevMode ? stringify(token) : ''); } else if (record.value === NOT_YET) { record.value = CIRCULAR; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7403d061f017..d63e6b6f598c 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -25,6 +25,8 @@ import {ERROR_DETAILS_PAGE_BASE_URL} from './error_details_base_url'; * - animations: 3000-3999 * - router: 4000-4999 * - platform-browser: 5000-5500 + * - service-worker: 5600-5699 + * - platform-server: 5700-5800 */ export const enum RuntimeErrorCode { // Change Detection Errors @@ -37,9 +39,9 @@ export const enum RuntimeErrorCode { PROVIDER_NOT_FOUND = -201, INVALID_FACTORY_DEPENDENCY = 202, MISSING_INJECTION_CONTEXT = -203, - INVALID_INJECTION_TOKEN = 204, - INJECTOR_ALREADY_DESTROYED = 205, - PROVIDER_IN_WRONG_CONTEXT = 207, + INVALID_INJECTION_TOKEN = -204, + INJECTOR_ALREADY_DESTROYED = -205, + PROVIDER_IN_WRONG_CONTEXT = -207, MISSING_INJECTION_TOKEN = 208, INVALID_MULTI_PROVIDER = -209, MISSING_DOCUMENT = 210, diff --git a/packages/core/src/event_delegation_utils.ts b/packages/core/src/event_delegation_utils.ts index 10cb9b1e943d..def106861d77 100644 --- a/packages/core/src/event_delegation_utils.ts +++ b/packages/core/src/event_delegation_utils.ts @@ -101,11 +101,33 @@ export const JSACTION_EVENT_CONTRACT = new InjectionToken( }, ); +// Tracks (event, element) pairs already dispatched by a real DOM listener +// post-hydration. Keyed per element so that jsaction can still replay the same +// event on a *different* element (e.g. incremental hydration replays on a +// deferred block's element, not the trigger that originally fired). Prevents +// double-invocation when a component hydrates before app stability (#67328). +const handledEventElements = new WeakMap>(); + +export function markEventHandledForElement(event: Event, element: Element): void { + // Guard: WeakMap requires object keys. Synthetic events from triggerEventHandler in tests + // may be null or primitives, which are not real DOM events and don't need tracking. + if (event == null || typeof event !== 'object') return; + let elements = handledEventElements.get(event); + if (!elements) { + elements = new WeakSet(); + handledEventElements.set(event, elements); + } + elements.add(element); +} + export function invokeListeners(event: Event, currentTarget: Element | null) { const handlerFns = currentTarget?.__jsaction_fns?.get(event.type); if (!handlerFns || !currentTarget?.isConnected) { return; } + if (currentTarget && handledEventElements.get(event)?.has(currentTarget)) { + return; + } for (const handler of handlerFns) { handler(event); } diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index ef500da720f3..95f3e69e7220 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -442,10 +442,24 @@ function serializeLContainer( } if (!isHydrateNeverBlock) { - Object.assign( - serializedView, - serializeLView(lContainer[i] as LView, parentDeferBlockId, context), - ); + // Skip serialization for component views that opted out of hydration via + // ngSkipHydration. This mirrors the guard in serializeLView for inline + // child components (see the Array.isArray branch below), but applies to + // components hosted inside an LContainer (e.g. created via + // ViewContainerRef.createComponent). Without this check, NG0503 is thrown + // when such a component receives projectable nodes even if ngSkipHydration + // is present on its host element (#67928). + const childHostElement = unwrapRNode(childLView[HOST]!); + if ( + childLView[TVIEW].type !== TViewType.Component || + childHostElement === null || + !(childHostElement as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME) + ) { + Object.assign( + serializedView, + serializeLView(lContainer[i] as LView, parentDeferBlockId, context), + ); + } } } diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index 861c58791ef8..b148c3bb3df6 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -107,7 +107,7 @@ export function cleanupLContainer(lContainer: LContainer) { * Walks over `LContainer`s and components registered within * this LView and invokes dehydrated views cleanup function for each one. */ -function cleanupLView(lView: LView) { +export function cleanupLView(lView: LView) { cleanupI18nHydrationData(lView); const tView = lView[TVIEW]; diff --git a/packages/core/src/hydration/error_handling.ts b/packages/core/src/hydration/error_handling.ts index bdeb2ab35c8f..ccfe1fdffbb8 100644 --- a/packages/core/src/hydration/error_handling.ts +++ b/packages/core/src/hydration/error_handling.ts @@ -14,10 +14,14 @@ import {HOST, LView, TVIEW} from '../render3/interfaces/view'; import {getParentRElement} from '../render3/node_manipulation'; import {unwrapRNode} from '../render3/util/view_utils'; +import {readPatchedData} from '../render3/context_discovery'; import {markRNodeAsHavingHydrationMismatch} from './utils'; +import {DOC_PAGE_BASE_URL} from '../../../core/src/error_details_base_url'; const AT_THIS_LOCATION = '<-- AT THIS LOCATION'; +const THIRD_PARTY_SCRIPTS_URL = `/guide/hydration#third-party-scripts-with-dom-manipulation`; + /** * Retrieves a user friendly string for a given TNodeType for use in * friendly error messages @@ -100,7 +104,18 @@ export function validateMatchingNode( } const footer = getHydrationErrorFooter(componentClassName); - const message = header + expected + actual + getHydrationAttributeNote() + footer; + let message = header + expected + actual + getHydrationAttributeNote() + footer; + + // Check both when a mismatching node is found AND when the expected node is missing, + // since third-party scripts can both inject extra nodes and remove existing ones. + if (!node || (node && isLikelyExternalSourceNode(node))) { + message += + `Note: It looks like this mismatch may have been caused by a third-party script or ` + + `browser extension that modified the DOM outside of Angular's control. ` + + `Angular hydration does not support nodes injected or removed outside of the Angular-managed DOM. ` + + `Note: If you know which element in the DOM this will be inserted, consider adding ngSkipHydration to prevent this error. \n\n`; + } + throw new RuntimeError(RuntimeErrorCode.HYDRATION_NODE_MISMATCH, message); } } @@ -413,11 +428,32 @@ function getHydrationErrorFooter(componentClassName?: string): string { `To fix this problem:\n` + ` * check ${componentInfo} component for hydration-related issues\n` + ` * check to see if your template has valid HTML structure\n` + + ` * check if there are any third-party scripts that manipulate the DOM. More info: ${DOC_PAGE_BASE_URL}${THIRD_PARTY_SCRIPTS_URL}\n` + ` * or skip hydration by adding the \`ngSkipHydration\` attribute ` + `to its host node in a template\n\n` ); } +/** + * Checks if a given RNode is likely to have been added by a third-party script + * or browser extension, by checking whether Angular has any knowledge of it + * via patched data. Nodes created and managed by Angular will always have + * patched data attached to them. + */ +function isLikelyExternalSourceNode(rNode: RNode): boolean { + const node = rNode as Node; + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + // If Angular has patched this node, it was created within Angular's context. + if (readPatchedData(node as HTMLElement)) { + return false; + } + // No patched data means Angular has no record of this node — + // it was likely injected by a third-party script or browser extension. + return true; +} + /** * An attribute related note for hydration errors */ diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index 28322c5408d6..58055d19fefb 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -1,4 +1,4 @@ -/** +/** * @license * Copyright Google LLC All Rights Reserved. * @@ -532,7 +532,7 @@ export function canHydrateNode(lView: LView, tNode: TNode): boolean { /** * Helper function to prepare text nodes for serialization by ensuring - * that seperate logical text blocks in the DOM remain separate after + * that separate logical text blocks in the DOM remain separate after * serialization. */ export function processTextNodeBeforeSerialization(context: HydrationContext, node: RNode) { diff --git a/packages/core/src/i18n/locale_data_api.ts b/packages/core/src/i18n/locale_data_api.ts index 6e4c371027d6..be2d19ebd80d 100644 --- a/packages/core/src/i18n/locale_data_api.ts +++ b/packages/core/src/i18n/locale_data_api.ts @@ -11,9 +11,11 @@ import {global} from '../util/global'; import localeEn from './locale_en'; /** - * This const is used to store the locale data registered with `registerLocaleData` + * This const is used to store the locale data registered with `registerLocaleData`. + * Use `Object.create(null)` to prevent prototype pollution. */ -let LOCALE_DATA: {[localeId: string]: any} = {}; +// tslint:disable-next-line:no-toplevel-property-access +let LOCALE_DATA: {[localeId: string]: any} = /* @__PURE__ */ Object.create(null); /** * Register locale data to be used internally by Angular. See the @@ -115,7 +117,7 @@ export function getLocaleData(normalizedLocale: string): any { * Helper function to remove all the locale data from `LOCALE_DATA`. */ export function unregisterAllLocaleData() { - LOCALE_DATA = {}; + LOCALE_DATA = Object.create(null); } /** diff --git a/packages/core/src/internal/get_closest_component_name.ts b/packages/core/src/internal/get_closest_component_name.ts index 53bf39a2092e..9d3809b5c7c3 100644 --- a/packages/core/src/internal/get_closest_component_name.ts +++ b/packages/core/src/internal/get_closest_component_name.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ComponentDef} from '../render3'; +import type {ComponentDef} from '../render3'; import {readPatchedLView} from '../render3/context_discovery'; import {isComponentHost, isLContainer, isLView} from '../render3/interfaces/type_checks'; import {HEADER_OFFSET, HOST, TVIEW} from '../render3/interfaces/view'; @@ -36,11 +36,11 @@ export function getClosestComponentName(node: Node): string | null { const tNode = getTNode(tView, i); if (isComponentHost(tNode)) { const def = tView.data[tNode.directiveStart + tNode.componentOffset] as ComponentDef<{}>; - const name = def.debugInfo?.className || def.type.name; + const name = getComponentName(def); // Note: the name may be an empty string if the class name is // dropped due to minification. In such cases keep going up the tree. - if (name) { + if (name !== null) { return name; } else { break; @@ -54,3 +54,15 @@ export function getClosestComponentName(node: Node): string | null { return null; } + +/** + * Gets the class name of a component from its definition. + * Warning! this function will return minified names if the name of the component is minified. The + * consumer of the function is responsible for resolving the minified name to its original name. + * @param tView TView that the node belongs to. + * @param tNode TNode from which to extract the component name. + */ +export function getComponentName(def: ComponentDef): string | null { + // Note: the name may be an empty string if the class name is dropped due to minification. + return def.debugInfo?.className || def.type.name || null; +} diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 48ff86ca24f7..6b2011d2c0a5 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -22,6 +22,7 @@ import {assertNodeInjector} from '../render3/assert'; import {ComponentFactory as R3ComponentFactory} from '../render3/component_ref'; import {getComponentDef} from '../render3/def_getters'; import {getParentInjectorLocation, NodeInjector} from '../render3/di'; +import {nativeInsertBefore} from '../render3/dom_node_manipulation'; import { CONTAINER_HEADER_OFFSET, DEHYDRATED_VIEWS, @@ -51,7 +52,6 @@ import { } from '../render3/interfaces/view'; import {assertTNodeType} from '../render3/node_assert'; import {destroyLView} from '../render3/node_manipulation'; -import {nativeInsertBefore} from '../render3/dom_node_manipulation'; import {getCurrentTNode, getLView} from '../render3/state'; import { getParentInjectorIndex, @@ -70,15 +70,15 @@ import { throwError, } from '../util/assert'; +import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; +import {addToEndOfViewTree} from '../render3/view/construction'; +import {addLViewToLContainer, createLContainer, detachView} from '../render3/view/container'; import {ComponentFactory, ComponentRef} from './component_factory'; import {createElementRef, ElementRef} from './element_ref'; import {NgModuleRef} from './ng_module_factory'; import {TemplateRef} from './template_ref'; import {EmbeddedViewRef, ViewRef} from './view_ref'; -import {addLViewToLContainer, createLContainer, detachView} from '../render3/view/container'; -import {addToEndOfViewTree} from '../render3/view/construction'; -import {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; -import {RuntimeError, RuntimeErrorCode} from '../errors'; /** * Represents a container where one or more views can be attached to a component. @@ -333,11 +333,7 @@ export function injectViewContainerRef(): ViewContainerRef { return createContainerRef(previousTNode, getLView()); } -const VE_ViewContainerRef = ViewContainerRef; - -// TODO(alxhub): cleaning up this indirection triggers a subtle bug in Closure in g3. Once the fix -// for that lands, this can be cleaned up. -const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { +class R3ViewContainerRef extends ViewContainerRef { constructor( private _lContainer: LContainer, private _hostTNode: TElementNode | TContainerNode | TElementContainerNode, @@ -694,7 +690,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { } return index; } -}; +} function getViewRefs(lContainer: LContainer): ViewRef[] | null { return lContainer[VIEW_REFS] as ViewRef[]; @@ -846,16 +842,23 @@ function populateDehydratedViewsInLContainerImpl( const currentRNode: RNode | null = getSegmentHead(hydrationInfo, noOffsetIndex); const serializedViews = hydrationInfo.data[CONTAINERS]?.[noOffsetIndex]; - ngDevMode && - assertDefined( - serializedViews, - 'Unexpected state: no hydration info available for a given TNode, ' + - 'which represents a view container.', - ); + if (serializedViews === undefined) { + ngDevMode && + console.warn( + 'Unexpected state: no hydration info available for a given TNode, ' + + 'which represents a view container.', + ); + + // This ViewContainerRef was created for an element through a query + // (for example `viewChild(..., {read: ViewContainerRef})`) and there + // is no corresponding serialized container data in hydration metadata. + // Fall back to creation mode and insert an anchor on demand. + return false; + } const [commentNode, dehydratedViews] = locateDehydratedViewsInContainer( currentRNode!, - serializedViews!, + serializedViews, ); if (ngDevMode) { diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index f768d9fbd707..4fae640b251c 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -404,6 +404,9 @@ export interface ComponentDecorator { * life-cycle hooks. For more information, see the * [Lifecycle Hooks](guide/components/lifecycle) guide. * + * HELPFUL: You may not use this interface to describe a class that is a component. Decorators do not affect the typing of the decorated classes. + * Use `Type` instead of `Type`. + * * @usageNotes * * ### Setting component inputs @@ -665,6 +668,8 @@ export interface Component extends Directive { */ export const Component: ComponentDecorator = makeDecorator( 'Component', + // TODO(jeanmeche): remove the ts-ignore when OnPush is the default + // @ts-ignore (c: Component = {}) => ({changeDetection: ChangeDetectionStrategy.Eager, ...c}), Directive, undefined, @@ -782,7 +787,7 @@ export interface InputDecorator { * class BankAccount { * // This property is bound using its original name. * // Defining argument required as true inside the Input Decorator - * // makes this property deceleration as mandatory + * // makes this property declaration as mandatory * @Input({ required: true }) bankName!: string; * // Argument alias makes this property value is bound to a different property name * // when this component is instantiated in a template. diff --git a/packages/core/src/metadata/do_bootstrap.ts b/packages/core/src/metadata/do_bootstrap.ts index 9fe8b151673c..d6babe7b1550 100644 --- a/packages/core/src/metadata/do_bootstrap.ts +++ b/packages/core/src/metadata/do_bootstrap.ts @@ -15,7 +15,7 @@ import {ApplicationRef} from '../application/application_ref'; * * Reference to the current application is provided as a parameter. * - * See ["Bootstrapping"](guide/ngmodules/bootstrapping). + * See ["Bootstrapping"](/guide/ngmodules/overview#bootstrapping-an-application). * * @usageNotes * The example below uses `ApplicationRef.bootstrap()` to render the diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 08b7de441853..e77c67a4039c 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -82,6 +82,8 @@ import {ViewRef} from './view_ref'; import {createLView, createTView, getInitialLViewFlagsFromDef} from './view/construction'; import {BINDING, Binding, BindingInternal, DirectiveWithBindings} from './dynamic_bindings'; import {NG_REFLECT_ATTRS_FLAG, NG_REFLECT_ATTRS_FLAG_DEFAULT} from '../ng_reflect'; +import {TracingService} from '../application/tracing'; +import {getComponentName} from '../internal/get_closest_component_name'; export class ComponentFactoryResolver extends AbstractComponentFactoryResolver { /** @@ -169,6 +171,7 @@ function createRootLViewEnvironment(rootLViewInjector: Injector): LViewEnvironme const sanitizer = rootLViewInjector.get(Sanitizer, null); const changeDetectionScheduler = rootLViewInjector.get(ChangeDetectionScheduler, null); + const tracingService = rootLViewInjector.get(TracingService, null, {optional: true}); let ngReflect = false; if (typeof ngDevMode === 'undefined' || ngDevMode) { @@ -180,6 +183,7 @@ function createRootLViewEnvironment(rootLViewInjector: Injector): LViewEnvironme sanitizer, changeDetectionScheduler, ngReflect, + tracingService, }; } @@ -265,100 +269,128 @@ export class ComponentFactory extends AbstractComponentFactory { try { const cmpDef = this.componentDef; ngDevMode && verifyNotAnOrphanComponent(cmpDef); - - const rootTView = createRootTView(rootSelectorOrNode, cmpDef, componentBindings, directives); const rootViewInjector = createRootViewInjector( cmpDef, environmentInjector || this.ngModule, injector, ); - const environment = createRootLViewEnvironment(rootViewInjector); - const hostRenderer = environment.rendererFactory.createRenderer(null, cmpDef); - const hostElement = rootSelectorOrNode - ? locateHostElement( - hostRenderer, - rootSelectorOrNode, - cmpDef.encapsulation, - rootViewInjector, - ) - : createHostElement(cmpDef, hostRenderer); - const hasInputBindings = - componentBindings?.some(isInputBinding) || - directives?.some((d) => typeof d !== 'function' && d.bindings.some(isInputBinding)); - - const rootLView = createLView( - null, - rootTView, - null, - LViewFlags.IsRoot | getInitialLViewFlagsFromDef(cmpDef), - null, - null, - environment, - hostRenderer, - rootViewInjector, - null, - retrieveHydrationInfo(hostElement, rootViewInjector, true /* isRootView */), - ); + const tracingService = environment.tracingService; - rootLView[HEADER_OFFSET] = hostElement; - - // rootView is the parent when bootstrapping - // TODO(misko): it looks like we are entering view here but we don't really need to as - // `renderView` does that. However as the code is written it is needed because - // `createRootComponentView` and `createRootComponent` both read global state. Fixing those - // issues would allow us to drop this. - enterView(rootLView); - - let componentView: LView | null = null; - - try { - const hostTNode = directiveHostFirstCreatePass( - HEADER_OFFSET, - rootLView, - TNodeType.Element, - '#host', - () => rootTView.directiveRegistry, - true, - 0, + if (tracingService && tracingService.componentCreate) { + return tracingService.componentCreate(getComponentName(cmpDef), () => + this.createComponentRef( + environment, + rootViewInjector, + projectableNodes, + rootSelectorOrNode, + directives, + componentBindings, + ), + ); + } else { + return this.createComponentRef( + environment, + rootViewInjector, + projectableNodes, + rootSelectorOrNode, + directives, + componentBindings, ); + } + } finally { + setActiveConsumer(prevConsumer); + } + } + + private createComponentRef( + environment: LViewEnvironment, + rootViewInjector: Injector, + projectableNodes?: any[][] | undefined, + rootSelectorOrNode?: any, + directives?: (Type | DirectiveWithBindings)[], + componentBindings?: Binding[], + ) { + const cmpDef = this.componentDef; + const rootTView = createRootTView(rootSelectorOrNode, cmpDef, componentBindings, directives); + + const hostRenderer = environment.rendererFactory.createRenderer(null, cmpDef); + const hostElement = rootSelectorOrNode + ? locateHostElement(hostRenderer, rootSelectorOrNode, cmpDef.encapsulation, rootViewInjector) + : createHostElement(cmpDef, hostRenderer); + const hasInputBindings = + componentBindings?.some(isInputBinding) || + directives?.some((d) => typeof d !== 'function' && d.bindings.some(isInputBinding)); + + const rootLView = createLView( + null, + rootTView, + null, + LViewFlags.IsRoot | getInitialLViewFlagsFromDef(cmpDef), + null, + null, + environment, + hostRenderer, + rootViewInjector, + null, + retrieveHydrationInfo(hostElement, rootViewInjector, true /* isRootView */), + ); - // ---- element instruction - setupStaticAttributes(hostRenderer, hostElement, hostTNode); - attachPatchData(hostElement, rootLView); + rootLView[HEADER_OFFSET] = hostElement; - // TODO(pk): this logic is similar to the instruction code where a node can have directives - createDirectivesInstances(rootTView, rootLView, hostTNode); - executeContentQueries(rootTView, hostTNode, rootLView); - directiveHostEndFirstCreatePass(rootTView, hostTNode); + // rootView is the parent when bootstrapping + // TODO(misko): it looks like we are entering view here but we don't really need to as + // `renderView` does that. However as the code is written it is needed because + // `createRootComponentView` and `createRootComponent` both read global state. Fixing those + // issues would allow us to drop this. + enterView(rootLView); - if (projectableNodes !== undefined) { - projectNodes(hostTNode, this.ngContentSelectors, projectableNodes); - } + let componentView: LView | null = null; - componentView = getComponentLViewByIndex(hostTNode.index, rootLView); + try { + const hostTNode = directiveHostFirstCreatePass( + HEADER_OFFSET, + rootLView, + TNodeType.Element, + '#host', + () => rootTView.directiveRegistry, + true, + 0, + ); - // TODO(pk): why do we need this logic? - rootLView[CONTEXT] = componentView[CONTEXT] as T; + // ---- element instruction + setupStaticAttributes(hostRenderer, hostElement, hostTNode); + attachPatchData(hostElement, rootLView); - renderView(rootTView, rootLView, null); - } catch (e) { - // Stop tracking the views if creation failed since - // the consumer won't have a way to dereference them. - if (componentView !== null) { - unregisterLView(componentView); - } - unregisterLView(rootLView); - throw e; - } finally { - profiler(ProfilerEvent.DynamicComponentEnd); - leaveView(); + // TODO(pk): this logic is similar to the instruction code where a node can have directives + createDirectivesInstances(rootTView, rootLView, hostTNode); + executeContentQueries(rootTView, hostTNode, rootLView); + directiveHostEndFirstCreatePass(rootTView, hostTNode); + + if (projectableNodes !== undefined) { + projectNodes(hostTNode, this.ngContentSelectors, projectableNodes); } - return new ComponentRef(this.componentType, rootLView, !!hasInputBindings); + componentView = getComponentLViewByIndex(hostTNode.index, rootLView); + + // TODO(pk): why do we need this logic? + rootLView[CONTEXT] = componentView[CONTEXT] as T; + + renderView(rootTView, rootLView, null); + } catch (e) { + // Stop tracking the views if creation failed since + // the consumer won't have a way to dereference them. + if (componentView !== null) { + unregisterLView(componentView); + } + unregisterLView(rootLView); + throw e; } finally { - setActiveConsumer(prevConsumer); + profiler(ProfilerEvent.DynamicComponentEnd); + leaveView(); } + + return new ComponentRef(this.componentType, rootLView, !!hasInputBindings); } } diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index ab29c44219df..b90a8aa3bacb 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -419,7 +419,7 @@ export function ɵɵdefineNgModule(def: { /** Unique ID for the module that is used with `getModuleFactory`. */ id?: string | null; -}): unknown { +}): NgModuleDef { return noSideEffects(() => { const res: NgModuleDef = { type: def.type, @@ -608,8 +608,8 @@ export function ɵɵdefinePipe(pipeDef: { * Whether the pipe is standalone. */ standalone?: boolean; -}): unknown { - return >{ +}): PipeDef { + return { type: pipeDef.type, name: pipeDef.name, factory: null, diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index 4b17d6170ab8..da7c7831ae08 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -27,6 +27,8 @@ export const BINDING: unique symbol = /* @__PURE__ */ Symbol('BINDING'); /** * A dynamically-defined binding targeting. * For example, `inputBinding('value', () => 123)` creates an input binding. + * + * @see [Binding inputs, outputs and setting host directives at creation](guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation) */ export interface Binding { readonly [BINDING]: unknown; @@ -50,6 +52,8 @@ export interface BindingInternal extends Binding { /** * Represents a dynamically-created directive with bindings targeting it specifically. + * + * @see [Binding inputs, outputs and setting host directives at creation](guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation) */ export interface DirectiveWithBindings { /** Directive type that should be created. */ diff --git a/packages/core/src/render3/features/inherit_definition_feature.ts b/packages/core/src/render3/features/inherit_definition_feature.ts index 968546850dee..93f5a01c60a5 100644 --- a/packages/core/src/render3/features/inherit_definition_feature.ts +++ b/packages/core/src/render3/features/inherit_definition_feature.ts @@ -10,6 +10,7 @@ import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {Type, Writable} from '../../interface/type'; import {EMPTY_ARRAY, EMPTY_OBJ} from '../../util/empty'; import {fillProperties} from '../../util/property'; +import {NG_COMP_DEF, NG_DIR_DEF} from '../fields'; import { ComponentDef, ContentQueriesFunction, @@ -45,13 +46,20 @@ export function ɵɵInheritDefinitionFeature( let shouldInheritFields = true; const inheritanceChain: WritableDef[] = [definition]; - while (superType) { + // Only accept defs declared on the current type to avoid polluted prototype members. + // Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance. + while (superType && superType !== Function.prototype && superType !== Object.prototype) { let superDef: DirectiveDef | ComponentDef | undefined = undefined; + const cmpDef = Object.hasOwn(superType, NG_COMP_DEF) + ? ((superType as any)[NG_COMP_DEF] as ComponentDef) + : undefined; + const dirDef = Object.hasOwn(superType, NG_DIR_DEF) + ? ((superType as any)[NG_DIR_DEF] as DirectiveDef) + : undefined; if (isComponentDef(definition)) { - // Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance. - superDef = superType.ɵcmp || superType.ɵdir; + superDef = cmpDef ?? dirDef; } else { - if (superType.ɵcmp) { + if (cmpDef) { throw new RuntimeError( RuntimeErrorCode.INVALID_INHERITANCE, ngDevMode && @@ -60,8 +68,7 @@ export function ɵɵInheritDefinitionFeature( )} is attempting to extend component ${stringifyForError(superType)}`, ); } - // Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance. - superDef = superType.ɵdir; + superDef = dirDef; } if (superDef) { diff --git a/packages/core/src/render3/hmr.ts b/packages/core/src/render3/hmr.ts index 2d6dd1eee3e6..465af090bd7b 100644 --- a/packages/core/src/render3/hmr.ts +++ b/packages/core/src/render3/hmr.ts @@ -34,6 +34,7 @@ import { TVIEW, } from './interfaces/view'; import {assertTNodeType} from './node_assert'; +import {cleanupLView as cleanupDehydratedLView} from '../hydration/cleanup'; import {destroyLView, removeViewFromDOM} from './node_manipulation'; import {RendererFactory} from './interfaces/renderer'; import {NgZone} from '../zone'; @@ -69,7 +70,7 @@ export function ɵɵgetReplaceMetadataURL(id: string, timestamp: string, base: s * Replaces the metadata of a component type and re-renders all live instances of the component. * @param type Class whose metadata will be replaced. * @param applyMetadata Callback that will apply a new set of metadata on the `type` when invoked. - * @param environment Syntehtic namespace imports that need to be passed along to the callback. + * @param environment Synthetic namespace imports that need to be passed along to the callback. * @param locals Local symbols from the source location that have to be exposed to the callback. * @param importMeta `import.meta` from the call site of the replacement function. Optional since * it isn't used internally. @@ -279,6 +280,12 @@ function recreateLView( // Destroy the detached LView. destroyLView(lView[TVIEW], lView); + // Clean up any dehydrated views left over from SSR hydration. + // Neither destroyLView nor removeViewFromDOM handle DOM nodes + // stored in LContainer[DEHYDRATED_VIEWS], which causes duplicated + // content when the view is re-rendered during HMR. + cleanupDehydratedLView(lView); + // Always force the creation of a new renderer to ensure state captured during construction // stays consistent with the new component definition by clearing any old ached factories. const rendererFactory = lView[ENVIRONMENT].rendererFactory; diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts index da5df8f35504..96745d19fbb6 100644 --- a/packages/core/src/render3/i18n/i18n_apply.ts +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -49,8 +49,10 @@ import { } from '../dom_node_manipulation'; import { getBindingIndex, + getSelectedIndex, isInSkipHydrationBlock, lastNodeWasCreated, + setSelectedIndex, wasLastNodeCreated, } from '../state'; import {renderStringify} from '../util/stringify_utils'; @@ -101,19 +103,22 @@ export function setMaskBit(hasChange: boolean) { } export function applyI18n(tView: TView, lView: LView, index: number) { - if (changeMaskCounter > 0) { - ngDevMode && assertDefined(tView, `tView should be defined`); - const tI18n = tView.data[index] as TI18n | I18nUpdateOpCodes; - // When `index` points to an `ɵɵi18nAttributes` then we have an array otherwise `TI18n` - const updateOpCodes: I18nUpdateOpCodes = Array.isArray(tI18n) - ? (tI18n as I18nUpdateOpCodes) - : (tI18n as TI18n).update; - const bindingsStartIndex = getBindingIndex() - changeMaskCounter - 1; - applyUpdateOpCodes(tView, lView, updateOpCodes, bindingsStartIndex, changeMask); + try { + if (changeMaskCounter > 0) { + ngDevMode && assertDefined(tView, `tView should be defined`); + const tI18n = tView.data[index] as TI18n | I18nUpdateOpCodes; + // When `index` points to an `ɵɵi18nAttributes` then we have an array otherwise `TI18n` + const updateOpCodes: I18nUpdateOpCodes = Array.isArray(tI18n) + ? (tI18n as I18nUpdateOpCodes) + : (tI18n as TI18n).update; + const bindingsStartIndex = getBindingIndex() - changeMaskCounter - 1; + applyUpdateOpCodes(tView, lView, updateOpCodes, bindingsStartIndex, changeMask); + } + } finally { + // Reset changeMask & maskBit to default for the next update cycle + changeMask = 0b0; + changeMaskCounter = 0; } - // Reset changeMask & maskBit to default for the next update cycle - changeMask = 0b0; - changeMaskCounter = 0; } function createNodeWithoutHydration( @@ -237,7 +242,7 @@ export function applyCreateOpCodes( * @param lView Current `LView` * @param anchorRNode place where the i18n node should be inserted. */ -export function applyMutableOpCodes( +function applyMutableOpCodes( tView: TView, mutableOpCodes: IcuCreateOpCodes, lView: LView, @@ -394,7 +399,7 @@ export function applyMutableOpCodes( * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from * `bindingsStartIndex`) */ -export function applyUpdateOpCodes( +function applyUpdateOpCodes( tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes, @@ -439,14 +444,20 @@ export function applyUpdateOpCodes( sanitizeFn, ); } else { - setPropertyAndInputs( - tNodeOrTagName, - lView, - propName, - value, - lView[RENDERER], - sanitizeFn, - ); + const prevSelectedIndex = getSelectedIndex(); + setSelectedIndex(nodeIndex); + try { + setPropertyAndInputs( + tNodeOrTagName, + lView, + propName, + value, + lView[RENDERER], + sanitizeFn, + ); + } finally { + setSelectedIndex(prevSelectedIndex); + } } break; case I18nUpdateOpCode.Text: diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index 5aab5ce2e143..4ef64c681618 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -9,14 +9,17 @@ import '../../util/ng_dev_mode'; import '../../util/ng_i18n_closure_mode'; import {XSS_SECURITY_URL} from '../../error_details_base_url'; -import { - getTemplateContent, - URI_ATTRS, - VALID_ATTRS, - VALID_ELEMENTS, -} from '../../sanitization/html_sanitizer'; +import {getTemplateContent, VALID_ATTRS, VALID_ELEMENTS} from '../../sanitization/html_sanitizer'; import {getInertBodyHelper} from '../../sanitization/inert_body'; import {_sanitizeUrl} from '../../sanitization/url_sanitizer'; +import { + ɵɵsanitizeHtml as _sanitizeHtml, + ɵɵsanitizeStyle as _sanitizeStyle, + ɵɵsanitizeScript as _sanitizeScript, + ɵɵsanitizeResourceUrl as _sanitizeResourceUrl, + ɵɵvalidateAttribute as _validateAttribute, +} from '../../sanitization/sanitization'; +import {SECURITY_SCHEMA, SecurityContext} from '../../sanitization/dom_security_schema'; import { assertDefined, assertEqual, @@ -70,6 +73,7 @@ import { } from './i18n_util'; import {createTNodeAtIndex} from '../tnode_manipulation'; import {allocExpando} from '../view/construction'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../namespaces'; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; @@ -382,13 +386,16 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str // the compiler treats static i18n attributes as regular attribute bindings. // Since this may not be the first i18n attribute on this element we need to pass in how // many previous bindings there have already been. + const tagName = previousElement.namespace + ? `:${previousElement.namespace}:${previousElement.value}` + : previousElement.value; generateBindingUpdateOpCodes( updateOpCodes, message, previousElementIndex, attrName, countBindings(updateOpCodes), - null, + i18nResolveSanitizer(attrName, tagName), ); } } @@ -808,21 +815,23 @@ function walkIcuTree( const attr = elAttrs.item(i)!; const lowerAttrName = attr.name.toLowerCase(); const hasBinding = !!attr.value.match(BINDING_REGEXP); - // we assume the input string is safe, unless it's using a binding + const elementNS = element.namespaceURI; + const tagNameWithNamespace = + elementNS === 'http://www.w3.org/2000/svg' + ? `:svg:${tagName}` + : elementNS === 'http://www.w3.org/1998/Math/MathML' + ? `:math:${tagName}` + : tagName; if (hasBinding) { if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { - if (URI_ATTRS[lowerAttrName]) { - generateBindingUpdateOpCodes( - update, - attr.value, - newIndex, - attr.name, - 0, - _sanitizeUrl, - ); - } else { - generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name, 0, null); - } + generateBindingUpdateOpCodes( + update, + attr.value, + newIndex, + attr.name, + 0, + i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace), + ); } else { ngDevMode && console.warn( @@ -831,8 +840,30 @@ function walkIcuTree( `(see ${XSS_SECURITY_URL})`, ); } + } else if (VALID_ATTRS[lowerAttrName]) { + let val = attr.value; + const sanitizer = i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace); + if (sanitizer) { + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + console.warn( + `WARNING: ignoring unsafe attribute ` + + `${lowerAttrName} on element ${tagName} ` + + `(see ${XSS_SECURITY_URL})`, + ); + } + + addCreateAttribute(create, newIndex, attr.name, 'unsafe:blocked'); + } else { + addCreateAttribute(create, newIndex, attr.name, val); + } } else { - addCreateAttribute(create, newIndex, attr); + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + console.warn( + `WARNING: ignoring unknown attribute name ` + + `${lowerAttrName} on element ${tagName} ` + + `(see ${XSS_SECURITY_URL})`, + ); + } } } const elementNode: I18nElementNode = { @@ -945,10 +976,63 @@ function addCreateNodeAndAppend( ); } -function addCreateAttribute(create: IcuCreateOpCodes, newIndex: number, attr: Attr) { - create.push( - (newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, - attr.name, - attr.value, - ); +function addCreateAttribute( + create: IcuCreateOpCodes, + newIndex: number, + attrName: string, + attrValue: string, +) { + create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue); +} + +function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex === -1) { + if (fatal) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } else { + return [null, elementName]; + } + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} + +function normalizeTagName(tagName: string): string { + const tagNameLower = tagName.toLowerCase(); + const [ns, name] = splitNsName(tagNameLower, false); + + return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name; +} + +function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { + const lowerAttrName = attrName.toLowerCase(); + const lowerTagName = tagName ? normalizeTagName(tagName) : '*'; + const schema = SECURITY_SCHEMA(); + const schemaContext = + schema[`${lowerTagName}|${lowerAttrName}`] || + schema[`*|${lowerAttrName}`] || + SecurityContext.NONE; + + switch (schemaContext) { + case SecurityContext.HTML: + return _sanitizeHtml; + case SecurityContext.STYLE: + return _sanitizeStyle; + case SecurityContext.SCRIPT: + return _sanitizeScript; + case SecurityContext.URL: + return _sanitizeUrl; + case SecurityContext.RESOURCE_URL: + return _sanitizeResourceUrl; + case SecurityContext.ATTRIBUTE_NO_BINDING: + return _validateAttribute; + default: + return null; + } } diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index d9a01f97c0ee..6e8597df319c 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -35,6 +35,7 @@ import { clearLViewNodeAnimationResolvers, enterClassMap, getClassListFromValue, + getEventTarget, getLViewEnterAnimations, getLViewLeaveAnimations, isLongestAnimation, @@ -106,6 +107,7 @@ export function runEnterAnimation( // bindings. const activeClasses = getClassListFromValue(value); const cleanupFns: VoidFunction[] = []; + let hasCompleted = false; // In the case where multiple animations are happening on the element, we need // to get the longest animation to ensure we don't complete animations early. @@ -113,7 +115,7 @@ export function runEnterAnimation( // gets removed early. const handleEnterAnimationStart = (event: AnimationEvent | TransitionEvent) => { // this early exit case is to prevent issues with bubbling events that are from child element animations - if (event.target !== nativeElement) return; + if (getEventTarget(event) !== nativeElement) return; const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend'; ngZone.runOutsideAngular(() => { @@ -124,8 +126,11 @@ export function runEnterAnimation( // When the longest animation ends, we can remove all the classes const handleEnterAnimationEnd = (event: AnimationEvent | TransitionEvent) => { // this early exit case is to prevent issues with bubbling events that are from child element animations - if (event.target !== nativeElement) return; + if (getEventTarget(event) !== nativeElement) return; + if (isLongestAnimation(event, nativeElement)) { + hasCompleted = true; + } enterAnimationEnd(event, nativeElement, renderer); }; @@ -147,6 +152,7 @@ export function runEnterAnimation( // preventing an animation via selector specificity. ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { + if (hasCompleted) return; determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported); if (!longestAnimations.has(nativeElement)) { for (const klass of activeClasses) { @@ -166,13 +172,13 @@ function enterAnimationEnd( ) { const elementData = enterClassMap.get(nativeElement); // this event.target check is to prevent issues with bubbling events that are from child element animations - if (event.target !== nativeElement || !elementData) return; + if (getEventTarget(event) !== nativeElement || !elementData) return; if (isLongestAnimation(event, nativeElement)) { // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. - event.stopImmediatePropagation(); + event.stopPropagation(); for (const klass of elementData.classList) { renderer.removeClass(nativeElement, klass); } @@ -317,17 +323,26 @@ function animateLeaveClassRunner( ) { cancelAnimationsIfRunning(el, renderer); const cleanupFns: VoidFunction[] = []; - const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers; + const componentResolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers; + let fallbackTimeoutId: number | undefined; + let hasCompleted = false; const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => { - // this early exit case is to prevent issues with bubbling events that are from child element animations - if (event.target !== el) return; - if (event instanceof CustomEvent || isLongestAnimation(event, el)) { + const target = getEventTarget(event as Event); + // Custom fallback events don't have a target, so we bypass this check for them. + if (target !== el && event.type !== 'animation-fallback') return; + + if ( + event.type === 'animation-fallback' || + isLongestAnimation(event as TransitionEvent | AnimationEvent, el) + ) { + hasCompleted = true; // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. - event.stopImmediatePropagation(); + if (fallbackTimeoutId) clearTimeout(fallbackTimeoutId); + if (event.type !== 'animation-fallback') event.stopPropagation(); longestAnimations.delete(el); clearLeavingNodes(tNode, el); @@ -339,7 +354,7 @@ function animateLeaveClassRunner( renderer.removeClass(el, item); } } - cleanupAfterLeaveAnimations(resolvers, cleanupFns); + cleanupAfterLeaveAnimations(componentResolvers, cleanupFns); clearLViewNodeAnimationResolvers(lView, tNode); } }; @@ -349,19 +364,34 @@ function animateLeaveClassRunner( cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd)); }); trackLeavingNodes(tNode, el); + for (const item of classList) { renderer.addClass(el, item); } + + // Force a reflow to ensure the browser registers the class addition and triggers the transition + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _reflow = el.offsetWidth; + // In the case that the classes added have no animations, we need to remove // the element right away. This could happen because someone is intentionally // preventing an animation via selector specificity. ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { + if (hasCompleted) return; determineLongestAnimation(el, longestAnimations, areAnimationSupported); - if (!longestAnimations.has(el)) { + const longest = longestAnimations.get(el); + if (!longest) { clearLeavingNodes(tNode, el); - cleanupAfterLeaveAnimations(resolvers, cleanupFns); + cleanupAfterLeaveAnimations(componentResolvers, cleanupFns); clearLViewNodeAnimationResolvers(lView, tNode); + } else { + // Fallback cleanup if the browser drops the transitionend/animationend event + // entirely due to off-screen optimizations or rapid DOM teardown. + fallbackTimeoutId = setTimeout(() => { + handleOutAnimationEnd(new CustomEvent('animation-fallback')); + }, longest.duration + 50) as unknown as number; + cleanupFns.push(() => clearTimeout(fallbackTimeoutId)); } }); }); diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index 413bbc74a293..135dd4b9d7ea 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -23,13 +23,23 @@ import { markRNodeAsSkippedByHydration, setSegmentHead, } from '../../hydration/utils'; +import {getComponentName} from '../../internal/get_closest_component_name'; import {assertDefined} from '../../util/assert'; import {assertTNodeCreationIndex} from '../assert'; import {clearElementContents, createElementNode} from '../dom_node_manipulation'; +import {ComponentDef} from '../interfaces/definition'; import {hasClassInput, hasStyleInput, TElementNode, TNode, TNodeType} from '../interfaces/node'; import {RElement} from '../interfaces/renderer_dom'; import {isComponentHost, isDirectiveHost} from '../interfaces/type_checks'; -import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TVIEW, TView} from '../interfaces/view'; +import { + ENVIRONMENT, + HEADER_OFFSET, + HYDRATION, + LView, + RENDERER, + TVIEW, + TView, +} from '../interfaces/view'; import {assertTNodeType} from '../node_assert'; import {executeContentQueries} from '../queries/query_execution'; import { @@ -100,6 +110,31 @@ export function ɵɵelementStart( ) : (tView.data[adjustedIndex] as TElementNode); + // If the node is a component host and we have a tracing service, we need to wrap the init logic. + if (isComponentHost(tNode)) { + const tracingService = lView[ENVIRONMENT].tracingService; + + if (tracingService && tracingService.componentCreate) { + const def = tView.data[tNode.directiveStart + tNode.componentOffset] as ComponentDef<{}>; + + return tracingService.componentCreate(getComponentName(def), () => { + initializeElement(index, name, lView, tNode, localRefsIndex); + return ɵɵelementStart; + }); + } + } + + initializeElement(index, name, lView, tNode, localRefsIndex); + return ɵɵelementStart; +} + +function initializeElement( + index: number, + name: string, + lView: LView, + tNode: TElementNode, + localRefsIndex: number | undefined, +) { elementLikeStartShared(tNode, lView, index, name, _locateOrCreateElementNode); if (isDirectiveHost(tNode)) { @@ -115,8 +150,6 @@ export function ɵɵelementStart( if (ngDevMode && lView[TVIEW].firstCreatePass) { validateElementIsKnown(lView, tNode); } - - return ɵɵelementStart; } /** diff --git a/packages/core/src/render3/instructions/queries.ts b/packages/core/src/render3/instructions/queries.ts index b52807e42410..bd510fad5886 100644 --- a/packages/core/src/render3/instructions/queries.ts +++ b/packages/core/src/render3/instructions/queries.ts @@ -35,7 +35,7 @@ import {isCreationMode} from '../util/view_utils'; export function ɵɵcontentQuery( directiveIndex: number, predicate: ProviderToken | string | string[], - flags: QueryFlags, + flags: number, read?: any, ): typeof ɵɵcontentQuery { createContentQuery(directiveIndex, predicate, flags, read); @@ -53,7 +53,7 @@ export function ɵɵcontentQuery( */ export function ɵɵviewQuery( predicate: ProviderToken | string | string[], - flags: QueryFlags, + flags: number, read?: any, ): typeof ɵɵviewQuery { createViewQuery(predicate, flags, read); diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 7836e226e51d..7fb7569037a8 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -12,10 +12,8 @@ import {hasSkipHydrationAttrOnRElement} from '../../hydration/skip_hydration'; import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens'; import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils'; import {ViewEncapsulation} from '../../metadata/view'; -import { - validateAgainstEventAttributes, - validateAgainstEventProperties, -} from '../../sanitization/sanitization'; +import {validateAgainstEventProperties} from '../../sanitization/sanitization'; + import {assertIndexInRange, assertNotSame} from '../../util/assert'; import {escapeCommentText} from '../../util/dom'; import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../ng_reflect'; @@ -23,6 +21,7 @@ import {stringify} from '../../util/stringify'; import {assertFirstCreatePass, assertHasParent, assertLView} from '../assert'; import {attachPatchData} from '../context_discovery'; import {getNodeInjectable, getOrCreateNodeInjectorForNode} from '../di'; +import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {throwMultipleComponentError} from '../errors'; import {ComponentDef, ComponentTemplate, DirectiveDef, RenderFlags} from '../interfaces/definition'; import { @@ -182,6 +181,12 @@ export function locateHostElement( encapsulation === ViewEncapsulation.ShadowDom || encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom; const rootElement = renderer.selectRootElement(elementOrSelector, preserveContent); + if (rootElement.tagName.toLowerCase() === 'script') { + throw new RuntimeError( + RuntimeErrorCode.UNSAFE_VALUE_IN_SCRIPT, + ngDevMode && `"`, + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('script')).toBeFalsy(); + }); +}); +describe('SVG link sanitization', () => { + it('should sanitize dynamic `href` bindings on ', () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should sanitize dynamic `xlink:href` bindings on ', () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp { + url = 'javascript:alert(1)'; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('xlink:href')).toEqual('unsafe:javascript:alert(1)'); + }); + + it('should allow static unsafe `href` and `xlink:href` on ', () => { + @Component({ + template: ` + + + + + `, + changeDetection: ChangeDetectionStrategy.Eager, + }) + class TestCmp {} + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const links = fixture.nativeElement.querySelectorAll('a'); + expect(links[0].getAttribute('href')).toEqual('javascript:alert(1)'); + expect(links[1].getAttribute('xlink:href')).toEqual('javascript:alert(2)'); + }); +}); diff --git a/packages/core/test/acceptance/tracing_spec.ts b/packages/core/test/acceptance/tracing_spec.ts index d39af7d28dc8..467a1363f898 100644 --- a/packages/core/test/acceptance/tracing_spec.ts +++ b/packages/core/test/acceptance/tracing_spec.ts @@ -10,6 +10,7 @@ import { afterEveryRender, Component, provideZoneChangeDetection, + signal, ɵTracingAction as TracingAction, ɵTracingService as TracingService, ɵTracingSnapshot as TracingSnapshot, @@ -27,13 +28,15 @@ describe('TracingService', () => { let fakeSnapshot: TracingSnapshot; let mockTracingService: TracingService; let clickCount: number; + let createdComponents: (string | null)[]; beforeEach(() => { actions = []; listeners = []; clickCount = 0; + createdComponents = []; fakeSnapshot = { - run: function (action: TracingAction, fn: () => T): T { + run: (action: TracingAction, fn: () => T): T => { actions.push(action); return fn(); }, @@ -54,6 +57,10 @@ describe('TracingService', () => { listeners.push({event, handler}); return handler; }), + componentCreate: (name, fn) => { + createdComponents.push(name); + return fn(); + }, }; }); @@ -126,4 +133,51 @@ describe('TracingService', () => { expect(clickCount).toBe(1); })); + + it('should trace component creations', () => { + TestBed.configureTestingModule({ + providers: [{provide: TracingService, useValue: mockTracingService}], + }); + + @Component({template: 'hello', selector: 'grandchild'}) + class GrandChild {} + + @Component({template: 'extra', selector: 'extra'}) + class Extra {} + + @Component({ + template: '', + selector: 'child', + imports: [GrandChild], + }) + class Child {} + + @Component({ + template: ` + + + @if (showExtra()) { + + }`, + imports: [Child, Extra], + }) + class App { + showExtra = signal(false); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(createdComponents).toEqual(['App', 'Child', 'Child', 'GrandChild', 'GrandChild']); + + fixture.componentInstance.showExtra.set(true); + fixture.detectChanges(); + expect(createdComponents).toEqual([ + 'App', + 'Child', + 'Child', + 'GrandChild', + 'GrandChild', + 'Extra', + ]); + }); }); diff --git a/packages/core/test/animation/longest_animation_spec.ts b/packages/core/test/animation/longest_animation_spec.ts new file mode 100644 index 000000000000..76fae451e144 --- /dev/null +++ b/packages/core/test/animation/longest_animation_spec.ts @@ -0,0 +1,431 @@ +/** + * @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 {determineLongestAnimation} from '../../src/animation/longest_animation'; +import {LongestAnimation} from '../../src/animation/interfaces'; +import {isNode} from '@angular/private/testing'; + +describe('determineLongestAnimation', () => { + if (isNode) { + it('should pass', () => expect(true).toBe(true)); + return; + } + + it('should immediately return if animations are not supported', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + spyOn(el, 'getAnimations').and.returnValue([{}] as unknown as Animation[]); + + determineLongestAnimation(el, animationsMap, false); + + expect(animationsMap.has(el)).toBeFalse(); + expect(el.getAnimations).not.toHaveBeenCalled(); + }); + + describe('with getAnimations() support', () => { + it('should find the longest animation among multiple animations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'anim-1', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 500, delay: 100, iterations: 1}), + }, + } as unknown as Animation, + { + animationName: 'anim-2', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 1000, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + { + animationName: 'anim-3', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 200, delay: 200, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + const longest = animationsMap.get(el); + expect(longest).toEqual({animationName: 'anim-2', propertyName: undefined, duration: 1000}); + }); + + it('should correctly identify CSSTransitions vs CSSAnimations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + transitionProperty: 'opacity', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 800, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + const longest = animationsMap.get(el); + expect(longest).toEqual({animationName: undefined, propertyName: 'opacity', duration: 800}); + }); + + it('should handle "auto" or undefined duration gracefully', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'bad-duration', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 'auto', delay: 200, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + const longest = animationsMap.get(el); + expect(longest).toEqual({ + animationName: 'bad-duration', + propertyName: undefined, + duration: 200, + }); + }); + + it('should skip animations with infinite iterations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'infinite-anim', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 1000, delay: 0, iterations: Infinity}), + }, + } as unknown as Animation, + { + animationName: 'finite-anim', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 500, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + const longest = animationsMap.get(el); + expect(longest).toEqual({ + animationName: 'finite-anim', + propertyName: undefined, + duration: 500, + }); + }); + + it('should ignore animations if their duration is 0', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'no-duration', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 0, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + expect(animationsMap.has(el)).toBeFalse(); + }); + + it('should not overwrite an existing longer animation in the map', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + animationsMap.set(el, { + animationName: 'existing-long-anim', + propertyName: undefined, + duration: 2000, + }); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'new-shorter-anim', + playbackRate: 1, + effect: { + getTiming: () => ({duration: 1000, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + const longest = animationsMap.get(el); + expect(longest).toEqual({ + animationName: 'existing-long-anim', + propertyName: undefined, + duration: 2000, + }); + }); + + it('should account for playback rate when determining animation duration', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + // Mock an animation with a playbackRate of 2 + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'mock-anim', + playbackRate: 2, + effect: { + getTiming: () => ({duration: 1000, delay: 0, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({animationName: 'mock-anim', propertyName: undefined, duration: 500}); + }); + + it('should handle negative playback rates by taking the absolute value', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([ + { + animationName: 'mock-anim', + playbackRate: -0.5, + effect: { + getTiming: () => ({duration: 500, delay: 100, iterations: 1}), + }, + } as unknown as Animation, + ]); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({ + animationName: 'mock-anim', + propertyName: undefined, + duration: 1200, + }); + }); + }); + + describe('with getComputedStyle() fallback', () => { + it('should calculate longest transition when there are no Element Animations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'opacity, transform'; + if (prop === 'transition-duration') return '0.5s, 800ms'; + if (prop === 'transition-delay') return '0s, 0.2s'; + if (prop === 'animation-name') return 'none'; + if (prop === 'animation-duration') return '0s'; + if (prop === 'animation-delay') return '0s'; + if (prop === 'animation-iteration-count') return '1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({ + propertyName: 'transform', + animationName: undefined, + duration: 1000, + }); + }); + + it('should calculate longest animation when there are no Element Animations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'all'; + if (prop === 'transition-duration') return '0s'; + if (prop === 'transition-delay') return '0s'; + if (prop === 'animation-name') return 'fade, slide'; + if (prop === 'animation-duration') return '500ms, 1s'; + if (prop === 'animation-delay') return '100ms, 0s'; + if (prop === 'animation-iteration-count') return '1, 1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({propertyName: undefined, animationName: 'slide', duration: 1000}); + }); + + it('should pick longest animation between transition and keyframe animation', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'opacity'; + if (prop === 'transition-duration') return '1s'; + if (prop === 'transition-delay') return '0s'; + if (prop === 'animation-name') return 'fade'; + if (prop === 'animation-duration') return '500ms'; + if (prop === 'animation-delay') return '0s'; + if (prop === 'animation-iteration-count') return '1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({propertyName: 'opacity', animationName: undefined, duration: 1000}); + }); + + it('should ignore computed infinite animations', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'all'; + if (prop === 'transition-duration') return '0s'; + if (prop === 'transition-delay') return '0s'; + if (prop === 'animation-name') return 'infinite-spin, slide'; + if (prop === 'animation-duration') return '10s, 1s'; + if (prop === 'animation-delay') return '0s, 0s'; + if (prop === 'animation-iteration-count') return 'infinite, 1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + // It should ignore the infinite one and pick slide (1s) + expect(longest).toEqual({propertyName: undefined, animationName: 'slide', duration: 1000}); + }); + + it('should not overwrite an existing longer animation with a computed style animation', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + animationsMap.set(el, { + animationName: 'existing-long-anim', + propertyName: undefined, + duration: 2000, + }); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'opacity'; + if (prop === 'transition-duration') return '1s'; + if (prop === 'transition-delay') return '0s'; + if (prop === 'animation-name') return 'none'; + if (prop === 'animation-duration') return '0s'; + if (prop === 'animation-delay') return '0s'; + if (prop === 'animation-iteration-count') return '1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({ + animationName: 'existing-long-anim', + propertyName: undefined, + duration: 2000, + }); + }); + + it('should ignore missing or 0 durations in computed styles', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'none'; + if (prop === 'transition-duration') return '0s'; + if (prop === 'transition-delay') return '0s'; + if (prop === 'animation-name') return 'none'; + if (prop === 'animation-duration') return '0s'; + if (prop === 'animation-delay') return '0s'; + if (prop === 'animation-iteration-count') return '1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + expect(animationsMap.has(el)).toBeFalse(); + }); + + it('should parse ms and missing time units correctly', () => { + const el = document.createElement('div'); + const animationsMap = new WeakMap(); + + spyOn(el, 'getAnimations').and.returnValue([]); + + const computedStyle = { + getPropertyValue: (prop: string) => { + if (prop === 'transition-property') return 'all'; + if (prop === 'transition-duration') return ''; + if (prop === 'transition-delay') return ''; + if (prop === 'animation-name') return 'anim'; + if (prop === 'animation-duration') return '200ms'; + if (prop === 'animation-delay') return '0s'; + if (prop === 'animation-iteration-count') return '1'; + return ''; + }, + } as CSSStyleDeclaration; + + spyOn(window, 'getComputedStyle').and.returnValue(computedStyle); + + determineLongestAnimation(el, animationsMap, true); + + const longest = animationsMap.get(el); + expect(longest).toEqual({propertyName: undefined, animationName: 'anim', duration: 200}); + }); + }); +}); diff --git a/packages/core/test/application_init_spec.ts b/packages/core/test/application_init_spec.ts index 146c2f6a9d29..12969afe903b 100644 --- a/packages/core/test/application_init_spec.ts +++ b/packages/core/test/application_init_spec.ts @@ -17,6 +17,7 @@ import {ERROR_DETAILS_PAGE_BASE_URL} from '../src/error_details_base_url'; import {EMPTY, Observable, Subscriber} from 'rxjs'; import {TestBed} from '../testing'; +import {timeout} from '@angular/private/testing'; describe('ApplicationInitStatus', () => { let status: ApplicationInitStatus; @@ -236,7 +237,7 @@ describe('ApplicationInitStatus', () => { TestBed.configureTestingModule({ providers: [ provideAppInitializer(async () => { - await new Promise((resolve) => setTimeout(resolve)); + await timeout(); isInitialized = true; }), ], diff --git a/packages/core/test/authoring/signal_input_signature_test.ts b/packages/core/test/authoring/signal_input_signature_test.ts index ba4a8c3c2e38..5f1d0a060aba 100644 --- a/packages/core/test/authoring/signal_input_signature_test.ts +++ b/packages/core/test/authoring/signal_input_signature_test.ts @@ -12,7 +12,7 @@ * the resulting types match our expectations (via comments asserting the `.d.ts`). */ -import {input} from '../../src/core'; +import {booleanAttribute, input, numberAttribute} from '../../src/core'; // import preserved to simplify `.d.ts` emit and simplify the `type_tester` logic. // tslint:disable-next-line no-duplicate-imports import {InputSignal, InputSignalWithTransform} from '../../src/core'; @@ -102,6 +102,19 @@ export class InputSignatureTest { transform: (v: string | boolean) => '', }); + /** boolean, boolean */ + explicitReadWithBooleanAttributeTransform = input(false, {transform: booleanAttribute}); + /** number, number */ + explicitReadWithNumberAttributeTransform = input(0, {transform: numberAttribute}); + /** boolean | undefined, boolean | undefined */ + explicitReadWithUndefinedInitialBooleanAttributeTransform = input(undefined, { + transform: booleanAttribute, + }); + /** number | undefined, number | undefined */ + explicitReadWithUndefinedInitialNumberAttributeTransform = input(undefined, { + transform: numberAttribute, + }); + /** string, string | boolean */ requiredWithTransformInferenceNoExplicitGeneric = input.required({ transform: (v: string | boolean) => '', diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 7381ee720cd6..917bc6efac95 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -323,7 +323,6 @@ "addServerStyles", "addToAnimationQueue", "addToEndOfViewTree", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -371,10 +370,8 @@ "cleanUpView", "cloakAndComputeStyles", "cloakElement", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "computeStaticStyling", "computeStyle", "concatStringsWithSpace", @@ -465,7 +462,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeOnDestroys", "executeTemplate", "executeViewQueryFn", @@ -494,6 +490,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -575,6 +572,7 @@ "initFeatures", "initTNodeFlags", "initializeDirectives", + "initializeElement", "initializeInputAndOutputAliases", "inject2", "injectArgs", @@ -782,6 +780,7 @@ "resolveTiming", "resolveTimingValue", "retrieveHydrationInfo", + "reusedNodes", "roundOffset", "runAfterLeaveAnimations", "runEffectsInView", @@ -830,7 +829,6 @@ "shouldBeIgnoredByZone", "shouldSearchParent", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "style", @@ -867,4 +865,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index c3e76b26fa04..aebfe088f690 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -198,7 +198,6 @@ "TracingService", "USE_VALUE", "UnsubscriptionError", - "VE_ViewContainerRef", "VIEW_REFS", "ViewContainerRef", "ViewEncapsulation", @@ -253,7 +252,6 @@ "addToArray", "addToEndOfViewTree", "addViewToDOM", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -288,10 +286,8 @@ "captureError", "checkStable", "cleanUpView", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "computeStaticStyling", "concatStringsWithSpace", "config", @@ -373,7 +369,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeListenerWithErrorHandling", "executeOnDestroys", "executeTemplate", @@ -398,6 +393,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -422,6 +418,7 @@ "getInsertInFrontOfRNodeWithNoI18n", "getLView", "getLViewParent", + "getNamespace", "getNativeByIndex", "getNativeByTNode", "getNearestLContainer", @@ -461,6 +458,7 @@ "handleStoppedNotification", "handleUncaughtError", "handleUnhandledError", + "handledEventElements", "hasApplyArgsData", "hasDeps", "hasInSkipHydrationBlockFlag", @@ -564,6 +562,7 @@ "map", "markAncestorsForTraversal", "markAsComponentHost", + "markEventHandledForElement", "markTransplantedViewsForRefresh", "markViewDirty", "markViewForRefresh", @@ -640,6 +639,7 @@ "resolveDirectives", "resolveForwardRef", "retrieveHydrationInfo", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -686,7 +686,6 @@ "stashEventListenerImpl", "storeLViewOnDestroy", "storeListenerCleanup", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", @@ -717,4 +716,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 1cd4ce15f57e..e22a30add6bb 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -296,7 +296,6 @@ "addToArray", "addToEndOfViewTree", "addViewToDOM", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -332,10 +331,8 @@ "checkStable", "classIndexOf", "cleanUpView", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "computeStaticStyling", "concatStringsWithSpace", "config", @@ -414,7 +411,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeOnDestroys", "executeTemplate", "executeViewQueryFn", @@ -440,6 +436,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -528,6 +525,7 @@ "initFeatures", "initTNodeFlags", "initializeDirectives", + "initializeElement", "initializeInputAndOutputAliases", "inject2", "injectArgs", @@ -679,6 +677,7 @@ "resolveDirectives", "resolveForwardRef", "retrieveHydrationInfo", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -725,7 +724,6 @@ "shouldTriggerDeferBlock", "storeLViewOnDestroy", "storeTriggerCleanupFn", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", @@ -753,4 +751,4 @@ "writeToDirectiveInput" ] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index e7b99dc5c3e7..b34b4ebe936f 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -263,7 +263,6 @@ "USE_VALUE", "UnsubscriptionError", "VALID", - "VE_ViewContainerRef", "VIEW_REFS", "Validators", "ValueChangeEvent", @@ -352,7 +351,6 @@ "addToEndOfViewTree", "addValidators", "addViewToDOM", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -402,10 +400,8 @@ "cleanUpView", "coerceToAsyncValidator", "coerceToValidator", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "collectResidual", "collectStylingFromDirectives", "collectStylingFromTAttrs", @@ -511,7 +507,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeListenerWithErrorHandling", "executeOnDestroys", "executeSchedule", @@ -555,6 +550,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getControlAsyncValidators", "getControlValidators", @@ -635,6 +631,7 @@ "handleStoppedNotification", "handleUncaughtError", "handleUnhandledError", + "handledEventElements", "hasApplyArgsData", "hasClassInput", "hasDeps", @@ -664,6 +661,7 @@ "initFeatures", "initTNodeFlags", "initializeDirectives", + "initializeElement", "initializeInputAndOutputAliases", "inject2", "injectArgs", @@ -786,6 +784,7 @@ "markDirtyIfOnPush", "markDuplicateOfResidualStyling", "markDuplicates", + "markEventHandledForElement", "markTransplantedViewsForRefresh", "markViewDirty", "markViewForRefresh", @@ -902,6 +901,7 @@ "resolveForwardRef", "resolveProvider", "retrieveHydrationInfo", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -1027,4 +1027,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 2322ce70ce85..7ff15eb23af3 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -260,7 +260,6 @@ "USE_VALUE", "UnsubscriptionError", "VALID", - "VE_ViewContainerRef", "VIEW_REFS", "ValueChangeEvent", "ViewContainerRef", @@ -356,7 +355,6 @@ "addToEndOfViewTree", "addValidators", "addViewToDOM", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -404,10 +402,8 @@ "cleanUpView", "coerceToAsyncValidator", "coerceToValidator", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "collectResidual", "collectStylingFromDirectives", "collectStylingFromTAttrs", @@ -513,7 +509,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeListenerWithErrorHandling", "executeOnDestroys", "executeSchedule", @@ -556,6 +551,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getControlAsyncValidators", "getControlValidators", @@ -636,6 +632,7 @@ "handleStoppedNotification", "handleUncaughtError", "handleUnhandledError", + "handledEventElements", "hasApplyArgsData", "hasClassInput", "hasDeps", @@ -664,6 +661,7 @@ "initFeatures", "initTNodeFlags", "initializeDirectives", + "initializeElement", "initializeInputAndOutputAliases", "inject2", "injectArgs", @@ -787,6 +785,7 @@ "markDirtyIfOnPush", "markDuplicateOfResidualStyling", "markDuplicates", + "markEventHandledForElement", "markTransplantedViewsForRefresh", "markViewDirty", "markViewForRefresh", @@ -901,6 +900,7 @@ "resolvedPromise", "resolvedPromise", "retrieveHydrationInfo", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -1028,4 +1028,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index cee174e569bf..5a94ed36ba7a 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -199,6 +199,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHA256_ROUND_CONSTANTS", "SIGNAL", "SIMPLE_CHANGES_STORE", "SKIP_HYDRATION_ATTR_NAME", @@ -231,6 +232,7 @@ "TracingAction", "TracingService", "TransferState", + "UNCACHEABLE_CACHE_CONTROL_DIRECTIVES", "USE_VALUE", "UnsubscriptionError", "VIEW_REFS", @@ -292,7 +294,6 @@ "addServerStyles", "addToAnimationQueue", "addToEndOfViewTree", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -325,6 +326,7 @@ "callHookInternal", "callHooks", "canHydrateNode", + "canUseOrCacheRequest", "cancelLeavingNodes", "captureError", "checkStable", @@ -336,10 +338,8 @@ "cleanupLView", "cleanupMatchingDehydratedViews", "clearElementContents", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "computeStaticStyling", "concatStringsWithSpace", "config", @@ -429,7 +429,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeOnDestroys", "executeSchedule", "executeTemplate", @@ -463,6 +462,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -534,9 +534,11 @@ "hasLift", "hasMatchingDehydratedView", "hasOnDestroy", + "hasOutgoingCredentials", "hasParentInjector", "hasSkipHydrationAttrOnRElement", "hasSkipHydrationAttrOnTNode", + "hasUncacheableCacheControl", "icuContainerIterate", "identity", "importProvidersFrom", @@ -595,6 +597,7 @@ "isIterable", "isLContainer", "isLView", + "isNonCacheableRequest", "isNotFound", "isObserver", "isPositive", @@ -722,7 +725,9 @@ "resolveForwardRef", "retrieveHydrationInfo", "retrieveHydrationInfoImpl", + "retrieveStateFromCache", "retrieveTransferredState", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -775,11 +780,11 @@ "skipTextNodes", "sortAndConcatParams", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "subscribeOn", "syncViewWithBlueprint", + "textEncoder", "throwInvalidWriteToSignalErrorFn", "throwProviderNotFoundError", "timeoutProvider", @@ -807,4 +812,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/image-directive/BUILD.bazel b/packages/core/test/bundling/image-directive/BUILD.bazel index 2a1f9db12174..e4ead4036c4d 100644 --- a/packages/core/test/bundling/image-directive/BUILD.bazel +++ b/packages/core/test/bundling/image-directive/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( "e2e/image-perf-warnings-lazy/image-perf-warnings-lazy.ts", "e2e/image-perf-warnings-oversized/image-perf-warnings-oversized.ts", "e2e/image-perf-warnings-oversized/svg-no-perf-oversized-warnings.ts", + "e2e/lcp-check-duplicate/lcp-check-duplicate.ts", "e2e/lcp-check/lcp-check.ts", "e2e/oversized-image/oversized-image.ts", "e2e/preconnect-check/preconnect-check.ts", diff --git a/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts index 84e57f8efc31..044bb93c0843 100644 --- a/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts +++ b/packages/core/test/bundling/image-directive/e2e/image-distortion/image-distortion.e2e-spec.ts @@ -22,7 +22,7 @@ describe('NgOptimizedImage directive', () => { await browser.get('/e2e/image-distortion-failing'); const logs = await collectBrowserLogs(logging.Level.WARNING); - expect(logs.length).toEqual(7); + expect(logs.length).toEqual(8); // Image loading order is not guaranteed, so all logs, rather than single entry // needs to be checked in order to test whether a given error message is present. const expectErrorMessageInLogs = (logs: logging.Entry[], message: string) => { @@ -54,6 +54,16 @@ describe('NgOptimizedImage directive', () => { '\\nTo fix this, update the width and height attributes.', ); + expectErrorMessageInLogs( + logs, + 'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' + + 'with the `ngSrc=\\"/e2e/a.png\\"`) has detected that ' + + 'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' + + '\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' + + '\\nSupplied width and height attributes: 222w x 25h (aspect-ratio: 8.88). ' + + '\\nTo fix this, update the width and height attributes.', + ); + // Images with incorrect styling expectErrorMessageInLogs( logs, diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts new file mode 100644 index 000000000000..e01ea9879aa2 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts @@ -0,0 +1,35 @@ +/** + * @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 + */ + +/* tslint:disable:no-console */ +import {browser, by, element} from 'protractor'; +import {logging} from 'selenium-webdriver'; + +import {collectBrowserLogs} from '../browser-logs-util'; + +describe('NgOptimizedImage directive', () => { + it('should log a warning when a `priority` is missing on an LCP image', async () => { + await browser.get('/e2e/lcp-check-duplicate'); + // Verify that both images were rendered. + const imgs = element.all(by.css('img')); + let srcB = await imgs.get(0).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + let srcA = await imgs.get(1).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + // The `b.png` and `a.png` images are used twice in a template. + srcB = await imgs.get(2).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + srcA = await imgs.get(3).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + + // Make sure that no warnings are in the console for image `a.png`, + // since the first instance has the `priority` attribute, and is the LCP element. + const logs = await collectBrowserLogs(logging.Level.SEVERE); + expect(logs.length).toEqual(0); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts new file mode 100644 index 000000000000..d4ef8a385906 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts @@ -0,0 +1,41 @@ +/** + * @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.io/license + */ + +import {NgOptimizedImage} from '@angular/common'; +import {Component} from '@angular/core'; + +@Component({ + selector: 'lcp-check', + imports: [NgOptimizedImage], + template: ` + + + +
+ + + + +
+ + + + + + + +
+ `, +}) +export class LcpCheckDuplicate {} diff --git a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts index 4f396b04aeba..bf4fce9c2dee 100644 --- a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts +++ b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {browser, by, element, ExpectedConditions} from 'protractor'; +import {browser} from 'protractor'; import {logging} from 'selenium-webdriver'; import {collectBrowserLogs} from '../browser-logs-util'; diff --git a/packages/core/test/bundling/image-directive/index.ts b/packages/core/test/bundling/image-directive/index.ts index 639db0294d2e..98b79b8c8cdb 100644 --- a/packages/core/test/bundling/image-directive/index.ts +++ b/packages/core/test/bundling/image-directive/index.ts @@ -27,6 +27,7 @@ import { } from './e2e/oversized-image/oversized-image'; import {PreconnectCheckComponent} from './e2e/preconnect-check/preconnect-check'; import {PlaygroundComponent} from './playground'; +import {LcpCheckDuplicate} from './e2e/lcp-check-duplicate/lcp-check-duplicate'; @Component({ selector: 'app-root', @@ -42,6 +43,7 @@ const ROUTES = [ // Paths below are used for e2e testing: {path: 'e2e/basic', component: BasicComponent}, {path: 'e2e/lcp-check', component: LcpCheckComponent}, + {path: 'e2e/lcp-check-duplicate', component: LcpCheckDuplicate}, {path: 'e2e/image-perf-warnings-lazy', component: ImagePerfWarningsLazyComponent}, {path: 'e2e/image-perf-warnings-oversized', component: ImagePerfWarningsOversizedComponent}, {path: 'e2e/svg-no-perf-oversized-warnings', component: SvgNoOversizedPerfWarningsComponent}, diff --git a/packages/core/test/bundling/package.json b/packages/core/test/bundling/package.json index 1ec4bc6c6ed7..b155ffec3592 100644 --- a/packages/core/test/bundling/package.json +++ b/packages/core/test/bundling/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@angular/animations": "workspace:*", - "@angular/build": "21.2.0-next.2", + "@angular/build": "21.2.9", "@angular/common": "workspace:*", "@angular/compiler-cli": "workspace:*", "@angular/compiler": "workspace:*", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index f66b37dabe3a..bee425356f94 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -119,7 +119,6 @@ "HEADER_OFFSET", "HOST", "HOST_ATTR", - "HREF_RESOURCE_TAGS", "HYDRATION", "HistoryStateManager", "HostAttributeToken", @@ -240,6 +239,7 @@ "REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT", "RENDERER", "REQUIRED_UNSET_VALUE", + "RESOURCE_MAP", "ROUTER_CONFIGURATION", "ROUTER_OUTLET_DATA", "ROUTER_PRELOADER", @@ -279,7 +279,6 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", - "SRC_RESOURCE_TAGS", "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", @@ -320,7 +319,6 @@ "UrlSegmentGroup", "UrlSerializer", "UrlTree", - "VE_ViewContainerRef", "VIEW_REFS", "ViewContainerRef", "ViewEncapsulation", @@ -408,7 +406,6 @@ "addViewToDOM", "advanceActivatedRoute", "afterNextNavigation", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -452,10 +449,8 @@ "checkStable", "classIndexOf", "cleanUpView", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "collectQueryResults", "combineLatest", "combineLatestInit", @@ -605,7 +600,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeListenerWithErrorHandling", "executeOnDestroys", "executeSchedule", @@ -656,6 +650,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -744,6 +739,7 @@ "handleStoppedNotification", "handleUncaughtError", "handleUnhandledError", + "handledEventElements", "hasApplyArgsData", "hasClassInput", "hasDeps", @@ -768,6 +764,7 @@ "initFeatures", "initTNodeFlags", "initializeDirectives", + "initializeElement", "initializeInputAndOutputAliases", "inject2", "injectArgs", @@ -902,6 +899,7 @@ "mapOneOrManyArgs", "markAncestorsForTraversal", "markAsComponentHost", + "markEventHandledForElement", "markTransplantedViewsForRefresh", "markViewDirty", "markViewForRefresh", @@ -1027,6 +1025,7 @@ "resolveForwardRef", "resolveNode", "retrieveHydrationInfo", + "reusedNodes", "rootRoute", "runAfterLeaveAnimations", "runCanActivate", @@ -1176,4 +1175,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index db9a26025e96..5a8fe10f7028 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -233,7 +233,6 @@ "addServerStyles", "addToAnimationQueue", "addToEndOfViewTree", - "aggregateDescendantAnimations", "allLeavingAnimations", "allocExpando", "allocLFrame", @@ -267,10 +266,8 @@ "captureError", "checkStable", "cleanUpView", - "collectAllViewLeaveAnimations", "collectNativeNodes", "collectNativeNodesInLContainer", - "collectNestedViewAnimations", "computeStaticStyling", "concatStringsWithSpace", "config", @@ -344,7 +341,6 @@ "executeCheckHooks", "executeContentQueries", "executeInitAndCheckHooks", - "executeLeaveAnimations", "executeOnDestroys", "executeTemplate", "executeViewQueryFn", @@ -365,6 +361,7 @@ "getComponentDef", "getComponentId", "getComponentLViewByIndex", + "getComponentName", "getConstant", "getCurrentDirectiveIndex", "getCurrentInjector", @@ -388,6 +385,7 @@ "getInsertInFrontOfRNodeWithNoI18n", "getLView", "getLViewParent", + "getNamespace", "getNativeByTNode", "getNearestLContainer", "getNextLContainer", @@ -572,6 +570,7 @@ "resolveDirectives", "resolveForwardRef", "retrieveHydrationInfo", + "reusedNodes", "runAfterLeaveAnimations", "runEffectsInView", "runInInjectionContext", @@ -612,7 +611,6 @@ "shouldBeIgnoredByZone", "shouldSearchParent", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", @@ -638,4 +636,4 @@ ], "lazy": [] } -} +} \ No newline at end of file diff --git a/packages/core/test/di/r3_injector_spec.ts b/packages/core/test/di/r3_injector_spec.ts index c1aba322548b..e02830580e72 100644 --- a/packages/core/test/di/r3_injector_spec.ts +++ b/packages/core/test/di/r3_injector_spec.ts @@ -15,10 +15,10 @@ import { ɵɵdefineInjector, ɵɵinject, } from '../../src/core'; -import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; import {createInjector} from '../../src/di/create_injector'; import {InternalInjectFlags} from '../../src/di/interface/injector'; import {R3Injector} from '../../src/di/r3_injector'; +import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; describe('InjectorDef-based createInjector()', () => { class CircularA { @@ -461,14 +461,14 @@ describe('InjectorDef-based createInjector()', () => { it('does not allow injection after destroy', () => { (injector as R3Injector).destroy(); expect(() => injector.get(DeepService)).toThrowError( - 'NG0205: Injector has already been destroyed.', + /NG0205: Injector has already been destroyed./, ); }); it('does not allow double destroy', () => { (injector as R3Injector).destroy(); expect(() => (injector as R3Injector).destroy()).toThrowError( - 'NG0205: Injector has already been destroyed.', + /NG0205: Injector has already been destroyed./, ); }); @@ -506,7 +506,7 @@ describe('InjectorDef-based createInjector()', () => { static ɵinj = ɵɵdefineInjector({providers: [MissingArgumentType]}); } expect(() => createInjector(ErrorModule).get(MissingArgumentType)).toThrowError( - "NG0204: Can't resolve all parameters for MissingArgumentType: (?).", + /NG0204: Can't resolve all parameters for MissingArgumentType: \(\?\)./, ); }); }); diff --git a/packages/core/test/error_handler_spec.ts b/packages/core/test/error_handler_spec.ts index 663f34cb31ed..976ea41c6ed5 100644 --- a/packages/core/test/error_handler_spec.ts +++ b/packages/core/test/error_handler_spec.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import {fakeAsync, TestBed} from '../testing'; +import {TestBed} from '../testing'; import {ErrorHandler, provideBrowserGlobalErrorListeners} from '../src/error_handler'; -import {isNode, withBody} from '@angular/private/testing'; +import {isNode, timeout, withBody} from '@angular/private/testing'; import {ApplicationRef, Component, destroyPlatform, inject} from '../src/core'; import {bootstrapApplication} from '@angular/platform-browser'; @@ -137,7 +137,7 @@ describe('ErrorHandler', () => { expect(dispatched).toEqual(true); // Wait until the error is re-thrown, so we can reset the original error handler. - await new Promise((resolve) => setTimeout(resolve, 1)); + await timeout(1); }); window.onerror = originalWindowOnError; diff --git a/packages/core/test/linker/ng_module_integration_spec.ts b/packages/core/test/linker/ng_module_integration_spec.ts index 5680166765bc..3557f027ac98 100644 --- a/packages/core/test/linker/ng_module_integration_spec.ts +++ b/packages/core/test/linker/ng_module_integration_spec.ts @@ -33,13 +33,13 @@ import {NgModuleType} from '../../src/render3'; import {getNgModuleDef} from '../../src/render3/def_getters'; import {ComponentFixture, inject, TestBed} from '../../testing'; +import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; import {InternalNgModuleRef, NgModuleFactory} from '../../src/linker/ng_module_factory'; import { clearModulesForTest, setAllowDuplicateNgModuleIdsForTest, } from '../../src/linker/ng_module_registration'; import {stringify} from '../../src/util/stringify'; -import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; class Engine {} @@ -542,7 +542,7 @@ describe('NgModule', () => { it('should throw when no type and not @Inject (class case)', () => { expect(() => createInjector([NoAnnotations])).toThrowError( - "NG0204: Can't resolve all parameters for NoAnnotations: (?).", + /NG0204: Can't resolve all parameters for NoAnnotations: \(\?\)./, ); }); diff --git a/packages/core/test/linker/security_integration_spec.ts b/packages/core/test/linker/security_integration_spec.ts index 212f88858e0e..e26076b679a2 100644 --- a/packages/core/test/linker/security_integration_spec.ts +++ b/packages/core/test/linker/security_integration_spec.ts @@ -7,6 +7,8 @@ */ import {Component, Directive, HostBinding, Input, NO_ERRORS_SCHEMA} from '../../src/core'; +import {clearTranslations, loadTranslations} from '@angular/localize'; +import {computeMsgId} from '@angular/compiler'; import {ComponentFixture, getTestBed, TestBed} from '../../testing'; import {DomSanitizer} from '@angular/platform-browser'; @@ -30,16 +32,14 @@ class OnPrefixDir { describe('security integration tests', function () { beforeEach(() => { + // Disable logging for these tests. + spyOn(console, 'log').and.callFake(() => {}); + TestBed.configureTestingModule({ declarations: [SecuredComponent, OnPrefixDir], }); }); - beforeEach(() => { - // Disable logging for these tests. - spyOn(console, 'log').and.callFake(() => {}); - }); - describe('events', () => { // this test is similar to the previous one, but since on-prefixed attributes validation now // happens at runtime, we need to invoke change detection to trigger elementProperty call @@ -48,8 +48,7 @@ describe('security integration tests', function () { TestBed.overrideComponent(SecuredComponent, {set: {template}}); expect(() => { - const cmp = TestBed.createComponent(SecuredComponent); - cmp.detectChanges(); + TestBed.createComponent(SecuredComponent); }).toThrowError( /Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../, ); @@ -89,6 +88,44 @@ describe('security integration tests', function () { expect(div.nativeElement.onclick).not.toBe(value); expect(div.nativeElement.hasAttribute('onclick')).toEqual(false); }); + + for (const ngDevModeValue of [true, false]) { + it(`should disallow binding to attr.on* in host bindings with ngDevMode=${ngDevModeValue}`, () => { + const originalNgDevMode = (globalThis as any).ngDevMode; + (globalThis as any).ngDevMode = ngDevModeValue; + + @Directive({ + selector: '[dirOnclick]', + standalone: false, + }) + class LocalHostOnclickDirective { + @HostBinding('attr.onclick') @Input() dirOnclick: string | undefined; + } + + @Component({ + selector: 'local-comp', + template: ``, + standalone: false, + }) + class LocalSecuredComponent { + ctxProp: any = 'some value'; + } + + try { + TestBed.configureTestingModule({ + declarations: [LocalSecuredComponent, LocalHostOnclickDirective], + }); + + expect(() => { + TestBed.createComponent(LocalSecuredComponent); + }).toThrowError( + /Binding to event attribute 'onclick' is disallowed for security reasons, please use \(click\)=.../, + ); + } finally { + (globalThis as any).ngDevMode = originalNgDevMode; + } + }); + } }); describe('safe HTML values', function () { @@ -164,6 +201,22 @@ describe('security integration tests', function () { checkEscapeOfHrefProperty(fixture); }); + it('should escape unsafe attributes on custom namespaced elements', () => { + const template = `Link Title`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + + it('should escape unsafe properties on custom namespaced elements', () => { + const template = `Link Title`; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + const fixture = TestBed.createComponent(SecuredComponent); + + checkEscapeOfHrefProperty(fixture); + }); + it('should escape unsafe properties if they are used in host bindings', () => { @Directive({ selector: '[dirHref]', @@ -239,6 +292,82 @@ describe('security integration tests', function () { }); describe('translation', () => { + afterEach(() => { + clearTranslations(); + }); + + it('should throw error on SVG animation retargeting attributes', () => { + const template = ` + + + + + + + `; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + expect(() => { + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + }).toThrowError( + /For security reasons, the `attributeName` can be set on the element as a static attribute only/i, + ); + }); + + it('should allow non-security sensitive attributes', () => { + loadTranslations({[computeMsgId('foo')]: 'bar'}); + const template = ``; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + const element = fixture.nativeElement.querySelector('iframe'); + expect(element.getAttribute('title')).toEqual('bar'); + }); + + it('should sanitize translations of static iframe attributes', () => { + const template = ``; + TestBed.overrideComponent(SecuredComponent, {set: {template}}); + + expect(() => { + const fixture = TestBed.createComponent(SecuredComponent); + fixture.detectChanges(); + }).toThrowError( + /For security reasons, the `sandbox` can be set on the `; TestBed.overrideComponent(SecuredComponent, {set: {template}}); diff --git a/packages/core/test/render3/BUILD.bazel b/packages/core/test/render3/BUILD.bazel index ecf74855b0cf..79b9e8cd284a 100644 --- a/packages/core/test/render3/BUILD.bazel +++ b/packages/core/test/render3/BUILD.bazel @@ -61,7 +61,6 @@ ts_project( "//packages/common", "//packages/compiler", "//packages/platform-server", - "//packages/platform-server:bundled_domino_lib", ], ) diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index 0f2b6ed873f7..1b395d55cc2f 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -155,6 +155,7 @@ describe('di', () => { sanitizer: null, changeDetectionScheduler: null, ngReflect: false, + tracingService: null, }, {} as any, null, diff --git a/packages/core/test/render3/i18n/i18n_parse_spec.ts b/packages/core/test/render3/i18n/i18n_parse_spec.ts index e2bde83db262..53fb556113e2 100644 --- a/packages/core/test/render3/i18n/i18n_parse_spec.ts +++ b/packages/core/test/render3/i18n/i18n_parse_spec.ts @@ -297,6 +297,50 @@ describe('i18n_parse', () => { ); }); }); + + it('should properly sanitize malicious URLs like `
` injected into translations', () => { + const tI18n = toT18n(`{ + �0�, select, + A {malicious JS} + other {malicious link} + }`); + + fixture.apply(() => { + applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null); + expect(fixture.host.innerHTML).toEqual(``); + }); + + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); + expect(fixture.host.innerHTML).toEqual( + `malicious JS`, + ); + }); + + fixture.apply(() => { + ɵɵi18nExp('other'); + ɵɵi18nApply(0); + expect(fixture.host.innerHTML).toEqual( + `malicious link`, + ); + }); + }); + + it('should ignore unknown attributes', () => { + const tI18n = toT18n(`{�0�, select, A {
} }`); + + fixture.apply(() => { + applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null); + expect(fixture.host.innerHTML).toEqual(``); + }); + + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); + expect(fixture.host.innerHTML).toEqual(`
`); + }); + }); }); function toT18n(text: string) { diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index d203833fd6a9..cfcbd5b92d7a 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -70,6 +70,7 @@ export function enterViewWithOneDiv() { sanitizer: null, changeDetectionScheduler: null, ngReflect: false, + tracingService: null, }, renderer, null, diff --git a/packages/core/test/render3/instructions_spec.ts b/packages/core/test/render3/instructions_spec.ts index 3cb2ebe1bb88..7edd0be76634 100644 --- a/packages/core/test/render3/instructions_spec.ts +++ b/packages/core/test/render3/instructions_spec.ts @@ -37,7 +37,7 @@ import { ɵɵsanitizeUrl, } from '../../src/sanitization/sanitization'; import {Sanitizer} from '../../src/sanitization/sanitizer'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SecurityContext} from '../../src/sanitization/dom_security_schema'; import {ViewFixture} from './view_fixture'; diff --git a/packages/core/test/render3/integration_spec.ts b/packages/core/test/render3/integration_spec.ts index 0d11e7095c3f..e69f9e72295b 100644 --- a/packages/core/test/render3/integration_spec.ts +++ b/packages/core/test/render3/integration_spec.ts @@ -13,7 +13,7 @@ import {TestBed} from '../../testing'; import {getLContext, readPatchedData} from '../../src/render3/context_discovery'; import {CONTEXT, HEADER_OFFSET} from '../../src/render3/interfaces/view'; import {Sanitizer} from '../../src/sanitization/sanitizer'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SecurityContext} from '../../src/sanitization/dom_security_schema'; describe('element discovery', () => { it('should only monkey-patch immediate child nodes in a component', () => { @@ -637,6 +637,76 @@ describe('sanitization', () => { expect(anchor.getAttribute('href')).toEqual('http://foo'); }); + + it('should throw when binding to animate element with attributeName="href"', () => { + @Component({ + selector: 'test-comp', + template: ``, + }) + class TestComp {} + + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection()], + }); + const fixture = TestBed.createComponent(TestComp); + expect(() => fixture.detectChanges()).toThrowError( + /Angular has detected that the `to` was applied/, + ); + }); + + it('should throw when binding to set element with attributeName="href"', () => { + @Component({ + selector: 'test-comp', + template: ``, + }) + class TestComp {} + + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection()], + }); + const fixture = TestBed.createComponent(TestComp); + expect(() => fixture.detectChanges()).toThrowError( + /Angular has detected that the `to` was applied/, + ); + }); + + // The SVG `attributeName` is case-sensitive when accessed via the DOM API + // (i.e. `setAttribute('attributename', ...)` and `setAttribute('attributeName', ...)` + // create two distinct attributes). However, the browser tokenizer normalizes + // the lowercase form `attributename` to `attributeName` on initial parsing, + // which means the client-side sanitizer still ends up seeing `attributeName`. + // The SSR renderer (Domino) does not perform this normalization, so we + // explicitly look up the lowercase form as well to make sure the sanitizer + // is triggered consistently in both environments. + it('should throw when binding to set element with attributename="href"', () => { + @Component({ + selector: 'test-comp', + template: ``, + }) + class TestComp {} + + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection()], + }); + const fixture = TestBed.createComponent(TestComp); + expect(() => fixture.detectChanges()).toThrowError( + /Angular has detected that the `to` was applied/, + ); + }); + + it('should not throw when binding to animate element when attributeName is not href', () => { + @Component({ + selector: 'test-comp', + template: ``, + }) + class TestComp {} + + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection()], + }); + const fixture = TestBed.createComponent(TestComp); + expect(() => fixture.detectChanges()).not.toThrow(); + }); }); class LocalSanitizedValue { diff --git a/packages/core/test/render3/is_shape_of.ts b/packages/core/test/render3/is_shape_of.ts index 0adadc1ac5b7..cc66b241b6fc 100644 --- a/packages/core/test/render3/is_shape_of.ts +++ b/packages/core/test/render3/is_shape_of.ts @@ -160,6 +160,7 @@ const ShapeOfTNode: ShapeOf = { flags: true, providerIndexes: true, value: true, + namespace: true, attrs: true, mergedAttrs: true, localNames: true, diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index bb36628a8e0e..2ec77dc8f929 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -117,6 +117,7 @@ export class ViewFixture { sanitizer: sanitizer || null, changeDetectionScheduler: null, ngReflect: false, + tracingService: null, }, hostRenderer, null, diff --git a/packages/core/test/sanitization/sanitization_spec.ts b/packages/core/test/sanitization/sanitization_spec.ts index bdfbc3882666..867b6dccb1bf 100644 --- a/packages/core/test/sanitization/sanitization_spec.ts +++ b/packages/core/test/sanitization/sanitization_spec.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import {SECURITY_SCHEMA} from '@angular/compiler'; import {ENVIRONMENT, LView} from '../../src/render3/interfaces/view'; import {enterView, leaveView} from '../../src/render3/state'; @@ -28,7 +27,7 @@ import { ɵɵtrustConstantHtml, ɵɵtrustConstantResourceUrl, } from '../../src/sanitization/sanitization'; -import {SecurityContext} from '../../src/sanitization/security'; +import {SECURITY_SCHEMA, SecurityContext} from '../../src/sanitization/dom_security_schema'; function fakeLView(): LView { const fake = [null, {}] as LView; @@ -118,19 +117,47 @@ describe('sanitization', () => { [SecurityContext.RESOURCE_URL, ɵɵsanitizeResourceUrl], ]); Object.entries(schema).forEach(([key, context]) => { - if (context === SecurityContext.URL || SecurityContext.RESOURCE_URL) { + if (context === SecurityContext.URL || context === SecurityContext.RESOURCE_URL) { const [tag, prop] = key.split('|'); const contexts = contextsByProp.get(prop) || new Set(); contexts.add(context); contextsByProp.set(prop, contexts); // check only in case a prop can be a part of both URL contexts if (contexts.size === 2) { - expect(getUrlSanitizer(tag, prop)).toEqual(sanitizerNameByContext.get(context)!); + expect(getUrlSanitizer(tag, prop)) + .withContext(`key: ${key}, context: ${context}`) + .toEqual(sanitizerNameByContext.get(context)!); } } }); }); + it('should select URL sanitizer case-insensitively', () => { + expect(getUrlSanitizer('IFRAME', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); + expect(getUrlSanitizer('IFRAME', 'src')).toEqual(ɵɵsanitizeResourceUrl); + expect(getUrlSanitizer('iframe', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); + expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeUrl); + expect(getUrlSanitizer('A', 'HREF')).toEqual(ɵɵsanitizeUrl); + }); + + it('should sanitize URL or ResourceURL case-insensitively', () => { + const ERROR = /NG0904: unsafe value used in a resource URL context.*/; + + expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'IFRAME', 'SRC')).toThrowError(ERROR); + + expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'IFRAME', 'src')).toThrowError(ERROR); + + expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'SRC')).toThrowError(ERROR); + + expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'ScRiPt', 'xLiNk:HrEf')).toEqual( + 'unsafe:javascript:true', + ); + + expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'A', 'HREF')).toEqual( + 'unsafe:javascript:true', + ); + }); + it('should sanitize resourceUrls via sanitizeUrlOrResourceUrl', () => { const ERROR = /NG0904: unsafe value used in a resource URL context.*/; expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'src')).toThrowError(ERROR); diff --git a/packages/core/test/signals/computed_spec.ts b/packages/core/test/signals/computed_spec.ts index 030b701a5eeb..aa9d235de9ea 100644 --- a/packages/core/test/signals/computed_spec.ts +++ b/packages/core/test/signals/computed_spec.ts @@ -200,6 +200,12 @@ describe('computed', () => { expect(double + '').toBe('[Computed: 2]'); }); + it('should have a toString implementation with debugName', () => { + const counter = signal(1); + const double = computed(() => counter() * 2, {debugName: 'double'}); + expect(double + '').toBe('[Computed (double): 2]'); + }); + it('should set debugName when a debugName is provided', () => { const primitiveSignal = signal(0); const node = computed(() => primitiveSignal(), {debugName: 'computedSignal'})[ diff --git a/packages/core/test/signals/linked_signal_spec.ts b/packages/core/test/signals/linked_signal_spec.ts index 848d57e5e28b..61b5392d0b9c 100644 --- a/packages/core/test/signals/linked_signal_spec.ts +++ b/packages/core/test/signals/linked_signal_spec.ts @@ -7,7 +7,7 @@ */ import {isSignal, linkedSignal, signal, computed} from '../../src/core'; -import {setPostProducerCreatedFn} from '../../primitives/signals'; +import {defaultEquals, setPostProducerCreatedFn} from '../../primitives/signals'; import {testingEffect} from './effect_util'; describe('linkedSignal', () => { @@ -55,7 +55,7 @@ describe('linkedSignal', () => { }); expect(choice()).toBe('apple'); - expect(choice.toString()).toBe('[LinkedSignal: apple]'); + expect(choice.toString()).toBe('[LinkedSignal (TestChoice): apple]'); }); it('should update when the source changes', () => { @@ -319,4 +319,125 @@ describe('linkedSignal', () => { expect(producers).toBe(2); setPostProducerCreatedFn(prev); }); + + describe('with custom equal', () => { + it('should cache exceptions thrown by equal', () => { + const s = signal(0); + + let computedRunCount = 0; + let equalRunCount = 0; + const c = linkedSignal( + () => { + computedRunCount++; + return s(); + }, + { + equal: () => { + equalRunCount++; + throw new Error('equal'); + }, + }, + ); + + // equal() isn't run for the initial computation. + expect(c()).toBe(0); + expect(computedRunCount).toBe(1); + expect(equalRunCount).toBe(0); + + s.set(1); + + // Error is thrown by equal(). + expect(() => c()).toThrowError('equal'); + expect(computedRunCount).toBe(2); + expect(equalRunCount).toBe(1); + + // Error is cached; c throws again without needing to rerun computation or equal(). + expect(() => c()).toThrowError('equal'); + expect(computedRunCount).toBe(2); + expect(equalRunCount).toBe(1); + }); + + it('should not track signal reads inside equal', () => { + const value = signal(1); + const epsilon = signal(0.5); + + let innerRunCount = 0; + let equalRunCount = 0; + const inner = linkedSignal( + () => { + innerRunCount++; + return value(); + }, + { + equal: (a, b) => { + equalRunCount++; + return Math.abs(a - b) < epsilon(); + }, + }, + ); + + let outerRunCount = 0; + const outer = linkedSignal(() => { + outerRunCount++; + return inner(); + }); + + // Everything runs the first time. + expect(outer()).toBe(1); + expect(innerRunCount).toBe(1); + expect(outerRunCount).toBe(1); + + // Difference is less than epsilon(). + value.set(1.2); + + // `inner` reruns because `value` was changed, and `equal` is called for the first time. + expect(outer()).toBe(1); + expect(innerRunCount).toBe(2); + expect(equalRunCount).toBe(1); + // `outer does not rerun because `equal` determined that `inner` had not changed. + expect(outerRunCount).toBe(1); + + // Previous difference is now greater than epsilon(). + epsilon.set(0.1); + + // While changing `epsilon` would change the outcome of the `inner`, we don't rerun it + // because we intentionally don't track reactive reads in `equal`. + expect(outer()).toBe(1); + expect(innerRunCount).toBe(2); + expect(equalRunCount).toBe(1); + // Equally important is that the signal read in `equal` doesn't leak into the outer reactive + // context either. + expect(outerRunCount).toBe(1); + }); + + it('should recover from exception', () => { + let shouldThrow = true; + const source = signal(0); + const derived = linkedSignal({ + source, + computation: (value, previous) => { + return `${value}, hasPrevious: ${previous !== undefined}`; + }, + equal: (a, b) => { + if (shouldThrow) { + throw new Error('equal'); + } + return defaultEquals(a, b); + }, + }); + + // Initial read doesn't throw because it doesn't call `equal`. + expect(derived()).toBe('0, hasPrevious: false'); + + // Update `source` to begin throwing. + source.set(1); + expect(() => derived()).toThrowError('equal'); + + // Stop throwing and update `source` to cause `derived` to recompute. No previous value + // should be made available as the linked signal transitions from an error state. + shouldThrow = false; + source.set(2); + expect(derived()).toBe('2, hasPrevious: false'); + }); + }); }); diff --git a/packages/core/test/signals/signal_spec.ts b/packages/core/test/signals/signal_spec.ts index 5be778ee4ba6..7ae249b4ef59 100644 --- a/packages/core/test/signals/signal_spec.ts +++ b/packages/core/test/signals/signal_spec.ts @@ -133,6 +133,11 @@ describe('signals', () => { expect(state + '').toBe('[Signal: false]'); }); + it('should have a toString implementation with debugName', () => { + const state = signal(false, {debugName: 'state'}); + expect(state + '').toBe('[Signal (state): false]'); + }); + it('should set debugName when a debugName is provided', () => { const node = signal(false, {debugName: 'falseSignal'})[SIGNAL] as ReactiveNode; expect(node.debugName).toBe('falseSignal'); diff --git a/packages/core/test/transfer_state_spec.ts b/packages/core/test/transfer_state_spec.ts index 8281b7754c86..adb0184834b6 100644 --- a/packages/core/test/transfer_state_spec.ts +++ b/packages/core/test/transfer_state_spec.ts @@ -13,7 +13,11 @@ import {DOCUMENT} from '../src/document'; import {makeStateKey, TransferState} from '../src/transfer_state'; function removeScriptTag(doc: Document, id: string) { - doc.getElementById(id)?.remove(); + let node = doc.getElementById(id); + while (node) { + node.remove(); + node = doc.getElementById(id); + } } function addScriptTag(doc: Document, appId: string, data: object | string) { @@ -57,6 +61,24 @@ describe('TransferState', () => { expect(transferState.get(TEST_KEY, 0)).toBe(10); }); + it('ignores non-script elements that clobber the transfer state id', () => { + const id = APP_ID + '-state'; + + const clobberingNode = doc.createElement('div'); + clobberingNode.id = id; + clobberingNode.textContent = '{"test":999}'; + doc.body.appendChild(clobberingNode); + + const script = doc.createElement('script'); + script.id = id; + script.setAttribute('type', 'application/json'); + script.textContent = '{"test":10}'; + doc.body.appendChild(script); + + const transferState: TransferState = TestBed.inject(TransferState); + expect(transferState.get(TEST_KEY, 0)).toBe(0); + }); + it('is initialized to empty state if script tag not found', () => { const transferState: TransferState = TestBed.inject(TransferState); expect(transferState.get(TEST_KEY, 0)).toBe(0); @@ -136,12 +158,18 @@ describe('TransferState', () => { transferState.set(DELAYED_KEY, '', ); }); diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts new file mode 100644 index 000000000000..878a659d371f --- /dev/null +++ b/packages/platform-server/test/url_spec.ts @@ -0,0 +1,111 @@ +/** + * @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 {resolveUrl} from '../src/url'; + +describe('resolveUrl', () => { + describe('with origin', () => { + it('should resolve relative paths against origin', () => { + const url = resolveUrl('/deep/path?query#hash', 'http://test.com'); + expect(url.href).toBe('http://test.com/deep/path?query#hash'); + expect(url.search).toBe('?query'); + expect(url.hash).toBe('#hash'); + }); + + it('should throw on backslash-prefixed hijack attempts', () => { + const urls = ['/\\attacker.com/deep/path', '\\\\attacker.com/deep/path']; + for (const url of urls) { + expect(() => resolveUrl(url, 'http://test.com')).toThrowError( + `NG05703: URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + ); + } + }); + + it('should resolve absolute URLs ignoring origin', () => { + const url = resolveUrl('http://other.com/deep/path', 'http://test.com'); + expect(url.href).toBe('http://other.com/deep/path'); + expect(url.origin).toBe('http://other.com'); + }); + + it('should throw when allowOriginChange is false and origin changes', () => { + expect(() => + resolveUrl('http://other.com/deep/path', 'http://test.com', {allowOriginChange: false}), + ).toThrowError( + `NG05703: URL http://other.com/deep/path changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + ); + }); + + it('should resolve same origin when allowOriginChange is false', () => { + const url = resolveUrl('http://test.com/other-path', 'http://test.com', { + allowOriginChange: false, + }); + expect(url.href).toBe('http://test.com/other-path'); + }); + + it('should resolve relative paths when allowOriginChange is false', () => { + const url = resolveUrl('/other-path', 'http://test.com', {allowOriginChange: false}); + expect(url.href).toBe('http://test.com/other-path'); + }); + + it('should throw an error for malformed absolute URLs', () => { + const malformedUrls = [ + 'http://evil.com:80:80/path', + 'https://evil.com:80:80/path', + 'http://[google.com]/path', + 'http://google.com:port/path', + 'http://google.com:80a/path', + ]; + + for (const url of malformedUrls) { + expect(() => resolveUrl(url, 'http://test.com')).toThrowError( + new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ); + } + }); + + it('should throw on obfuscated protocols attempting to change origin', () => { + const url = 'ht\ntp://evil.com/path'; + expect(() => resolveUrl(url, 'http://test.com')).toThrowError( + `NG05703: URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + ); + }); + }); + + describe('without origin', () => { + it('should return null for relative paths', () => { + expect(resolveUrl('/deep/path?query#hash')).toBeNull(); + expect(resolveUrl('deep/path')).toBeNull(); + expect(resolveUrl('/\\attacker.com/deep/path')).toBeNull(); + expect(resolveUrl('\\\\attacker.com/deep/path')).toBeNull(); + }); + + it('should parse valid absolute URLs', () => { + const url = resolveUrl('http://other.com/deep/path'); + expect(url).not.toBeNull(); + expect(url!.href).toBe('http://other.com/deep/path'); + expect(url!.origin).toBe('http://other.com'); + }); + + it('should throw an error for malformed absolute URLs', () => { + const malformedUrls = [ + 'http://evil.com:80:80/path', + 'https://evil.com:80:80/path', + 'http://[google.com]/path', + 'http://google.com:port/path', + 'http://google.com:80a/path', + 'ht\ntp://evil.com:80:80/path', + ]; + + for (const url of malformedUrls) { + expect(() => resolveUrl(url)).toThrowError( + new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ); + } + }); + }); +}); diff --git a/packages/platform-server/test/utils_spec.ts b/packages/platform-server/test/utils_spec.ts new file mode 100644 index 000000000000..198eae72e329 --- /dev/null +++ b/packages/platform-server/test/utils_spec.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, destroyPlatform, NgModule} from '@angular/core'; +import {renderApplication, renderModule, ServerModule} from '@angular/platform-server'; +import {isHostAllowed} from '../src/utils'; + +@Component({ + selector: 'app', + template: 'works!', + standalone: false, +}) +class MockComponent {} + +@NgModule({ + declarations: [MockComponent], + bootstrap: [MockComponent], + imports: [ServerModule], +}) +class MockNgModule {} + +describe('isHostAllowed', () => { + it('allows matching hostname when in allowedHosts list', () => { + expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue(); + }); + + it('allows matching hostname when wildcard matches', () => { + expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue(); + }); + + it('rejects hostname when not in allowedHosts list', () => { + expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse(); + }); + + it('allows all hostnames when * is in allowedHosts list', () => { + expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue(); + }); +}); + +describe('allowedHosts validation in renderApplication', () => { + const mockApplicationRef = { + injector: { + get: (token: any, defaultValue?: any) => defaultValue, + }, + whenStable: () => Promise.resolve(), + components: [], + } as any; + const bootstrap = (async () => mockApplicationRef) as any; + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should reject URLs with wrong host', async () => { + const relativeUrls = ['http://evil.com/deep/path', 'ht\ttp://evil.com/deep/path']; + + for (const url of relativeUrls) { + await expectAsync( + renderApplication(bootstrap, { + document: '', + url, + allowedHosts: ['test.com', 'localhost'], + }), + ) + .withContext(`URL: ${url}`) + .toBeRejectedWithError(/Host .+ is not allowed/); + } + }); + + it('should not throw a host validation error on bootstrap if host is allowed', async () => { + try { + await renderApplication(bootstrap, { + document: '', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); + + it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => { + const malformedUrls = [ + 'http://evil.com:80:80/path', + 'https://evil.com:80:80/path', + 'http://[google.com]/path', + 'http://google.com:port/path', + 'http://google.com:80a/path', + 'ht\ttp://evil.com:80:80/path', + 'ht\ntp://evil.com:80:80/path', + ]; + + for (const url of malformedUrls) { + await expectAsync( + renderApplication(bootstrap, { + document: '', + url, + allowedHosts: ['test.com'], + }), + ) + .withContext(`URL: ${url}`) + .toBeRejectedWithError(new RegExp(/Invalid URL:.+/)); + } + }); +}); + +describe('allowedHosts validation in renderModule', () => { + class MockModule {} + + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('should throw an error if host is not allowed', async () => { + await expectAsync( + renderModule(MockNgModule, { + document: '', + url: 'http://evil.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }), + ).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/); + }); + + it('should not throw a host validation error if host is allowed', async () => { + try { + await renderModule(MockModule, { + document: '', + url: 'http://test.com/deep/path', + allowedHosts: ['test.com', '*.example.com'], + }); + } catch (error: any) { + expect(error.message).not.toContain('is not allowed'); + } + }); + + it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => { + const malformedUrls = [ + 'http://evil.com:80:80/path', + 'https://evil.com:80:80/path', + 'http://[google.com]/path', + 'http://google.com:port/path', + 'http://google.com:80a/path', + 'ht\ttp://evil.com:80:80/path', + 'ht\ntp://evil.com:80:80/path', + ]; + + for (const url of malformedUrls) { + await expectAsync( + renderModule(MockNgModule, { + document: '', + url, + allowedHosts: ['test.com'], + }), + ) + .withContext(`URL: ${url}`) + .toBeRejectedWithError(new RegExp(/Invalid URL:.+/)); + } + }); +}); diff --git a/packages/platform-server/third_party/domino/BUILD.bazel b/packages/platform-server/third_party/domino/BUILD.bazel new file mode 100644 index 000000000000..773af5092400 --- /dev/null +++ b/packages/platform-server/third_party/domino/BUILD.bazel @@ -0,0 +1,53 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@npm//:rollup/package_json.bzl", rollup = "bin") + +package(default_visibility = ["//visibility:private"]) + +rollup.rollup( + name = "bundled_domino", + srcs = [ + "//:node_modules/@rollup/plugin-commonjs", + "//:node_modules/domino", + ], + outs = [ + "bundled-domino.mjs", + "bundled-domino.mjs.map", + ], + args = [ + "--format=esm", + "--plugin=@rollup/plugin-commonjs", + "--input=node_modules/domino/lib/index.js", + "--sourcemap=true", + "--file=packages/platform-server/third_party/domino/bundled-domino.mjs", + ], + progress_message = "Bundling domino", + silent_on_success = True, +) + +genrule( + name = "bundled_domino_license", + srcs = [ + "//:node_modules/domino/dir", + ], + outs = ["LICENSE"], + cmd = """ + cp "$(location //:node_modules/domino/dir)/LICENSE" $@ + """, +) + +js_library( + name = "bundled_domino_lib", + srcs = [ + "bundled-domino.d.ts", + ":bundled_domino", + ":bundled_domino_license", + ], + visibility = [ + "//packages/platform-server:__subpackages__", + "//packages/private/testing:__pkg__", + "//tools/testing:__pkg__", + ], + deps = [ + "//:node_modules/domino", + ], +) diff --git a/packages/platform-server/init/src/bundled-domino.d.ts b/packages/platform-server/third_party/domino/bundled-domino.d.ts similarity index 82% rename from packages/platform-server/init/src/bundled-domino.d.ts rename to packages/platform-server/third_party/domino/bundled-domino.d.ts index b928dc3d4832..964533f19b17 100644 --- a/packages/platform-server/init/src/bundled-domino.d.ts +++ b/packages/platform-server/third_party/domino/bundled-domino.d.ts @@ -6,6 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ + +declare module 'domino' { + export const impl: any; +} + import domino from 'domino'; export default domino; diff --git a/packages/private/testing/BUILD.bazel b/packages/private/testing/BUILD.bazel index d3be2711ac38..0a073ac26def 100644 --- a/packages/private/testing/BUILD.bazel +++ b/packages/private/testing/BUILD.bazel @@ -18,6 +18,6 @@ ng_project( "//packages/core", "//packages/core/testing", "//packages/platform-browser", - "//packages/platform-server:bundled_domino_lib", + "//packages/platform-server/third_party/domino:bundled_domino_lib", ], ) diff --git a/packages/private/testing/src/utils.ts b/packages/private/testing/src/utils.ts index 80b66becf591..d37fc475e17b 100644 --- a/packages/private/testing/src/utils.ts +++ b/packages/private/testing/src/utils.ts @@ -94,19 +94,20 @@ let savedRequestAnimationFrame: ((callback: FrameRequestCallback) => number) | u let savedNode: typeof Node | undefined = undefined; let requestAnimationFrameCount = 0; let domino: - | (typeof import('../../../platform-server/src/bundled-domino'))['default'] + | (typeof import('../../../platform-server/third_party/domino/bundled-domino'))['default'] | null | undefined = undefined; async function loadDominoOrNull(): Promise< - (typeof import('../../../platform-server/src/bundled-domino'))['default'] | null + (typeof import('../../../platform-server/third_party/domino/bundled-domino'))['default'] | null > { if (domino !== undefined) { return domino; } try { - return (domino = (await import('../../../platform-server/src/bundled-domino')).default); + return (domino = (await import('../../../platform-server/third_party/domino/bundled-domino')) + .default); } catch { return (domino = null); } @@ -208,3 +209,50 @@ export function useAutoTick() { jasmine.clock().uninstall(); }); } + +export interface WaitForOptions { + timeout?: number; + interval?: number; +} + +// Intentionally does not participate in fake clocks. +const realNow = performance.now.bind(performance); +const realSetTimeout = setTimeout; + +export async function waitFor( + callback: () => Promise | T, + options: WaitForOptions = {}, +): Promise { + const waitTime = options.timeout ?? 100; + const interval = options.interval ?? 0; + const stack = new Error().stack; + + const deadline = realNow() + waitTime; + let i = 0; + let lastError: any | undefined; + + while (true) { + try { + return await callback(); + } catch (cause) { + lastError = cause; + } + + i++; + + if (deadline < realNow()) { + throw Object.assign( + new Error( + `Timed out after ${waitTime}ms and ${i} attempts. ` + + `Last error: ${lastError?.message ?? 'condition returned false'}`, + ), + { + stack: stack + `Last error: ${lastError?.stack ?? 'condition returned false'}`, + }, + ); + } + + // Guarantee a macro-task between retries. + await new Promise((resolve) => void realSetTimeout(resolve, interval)); + } +} diff --git a/packages/router/src/components/empty_outlet.ts b/packages/router/src/components/empty_outlet.ts index 7911dbdab300..6c4ef179e3d8 100644 --- a/packages/router/src/components/empty_outlet.ts +++ b/packages/router/src/components/empty_outlet.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component} from '@angular/core'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterOutlet} from '../directives/router_outlet'; -import {PRIMARY_OUTLET} from '../shared'; import {Route} from '../models'; +import {PRIMARY_OUTLET} from '../shared'; export {ɵEmptyOutletComponent as EmptyOutletComponent}; /** @@ -27,6 +27,7 @@ export {ɵEmptyOutletComponent as EmptyOutletComponent}; imports: [RouterOutlet], // Used to avoid component ID collisions with user code. exportAs: 'emptyRouterOutlet', + changeDetection: ChangeDetectionStrategy.Eager, }) export class ɵEmptyOutletComponent {} diff --git a/packages/router/src/events.ts b/packages/router/src/events.ts index 8a3e6f8144ca..12e9c3988698 100644 --- a/packages/router/src/events.ts +++ b/packages/router/src/events.ts @@ -7,9 +7,9 @@ */ import {NavigationBehaviorOptions, Route} from './models'; +import type {Navigation} from './navigation_transition'; import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state'; import {UrlTree} from './url_tree'; -import type {Navigation} from './navigation_transition'; /** * Identifies the call or event that triggered a navigation. @@ -135,7 +135,7 @@ export class NavigationStart extends RouterEvent { this.restoredState = restoredState; } - /** @docsNotRequired */ + /** @docs-private */ override toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; } @@ -164,7 +164,7 @@ export class NavigationEnd extends RouterEvent { super(id, url); } - /** @docsNotRequired */ + /** @docs-private */ override toString(): string { return `NavigationEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}')`; } @@ -255,7 +255,7 @@ export class NavigationCancel extends RouterEvent { super(id, url); } - /** @docsNotRequired */ + /** @docs-private */ override toString(): string { return `NavigationCancel(id: ${this.id}, url: '${this.url}')`; } @@ -331,7 +331,7 @@ export class NavigationError extends RouterEvent { super(id, url); } - /** @docsNotRequired */ + /** @docs-private */ override toString(): string { return `NavigationError(id: ${this.id}, url: '${this.url}', error: ${this.error})`; } @@ -358,7 +358,7 @@ export class RoutesRecognized extends RouterEvent { super(id, url); } - /** @docsNotRequired */ + /** @docs-private */ override toString(): string { return `RoutesRecognized(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; } @@ -387,6 +387,7 @@ export class GuardsCheckStart extends RouterEvent { super(id, url); } + /** @docs-private */ override toString(): string { return `GuardsCheckStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; } @@ -417,6 +418,7 @@ export class GuardsCheckEnd extends RouterEvent { super(id, url); } + /** @docs-private */ override toString(): string { return `GuardsCheckEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state}, shouldActivate: ${this.shouldActivate})`; } @@ -448,6 +450,7 @@ export class ResolveStart extends RouterEvent { super(id, url); } + /** @docs-private */ override toString(): string { return `ResolveStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; } @@ -475,6 +478,7 @@ export class ResolveEnd extends RouterEvent { super(id, url); } + /** @docs-private */ override toString(): string { return `ResolveEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; } @@ -494,6 +498,8 @@ export class RouteConfigLoadStart { /** @docsNotRequired */ public route: Route, ) {} + + /** @docs-private */ toString(): string { return `RouteConfigLoadStart(path: ${this.route.path})`; } @@ -513,6 +519,8 @@ export class RouteConfigLoadEnd { /** @docsNotRequired */ public route: Route, ) {} + + /** @docs-private */ toString(): string { return `RouteConfigLoadEnd(path: ${this.route.path})`; } @@ -533,6 +541,8 @@ export class ChildActivationStart { /** @docsNotRequired */ public snapshot: ActivatedRouteSnapshot, ) {} + + /** @docs-private */ toString(): string { const path = (this.snapshot.routeConfig && this.snapshot.routeConfig.path) || ''; return `ChildActivationStart(path: '${path}')`; @@ -553,6 +563,8 @@ export class ChildActivationEnd { /** @docsNotRequired */ public snapshot: ActivatedRouteSnapshot, ) {} + + /** @docs-private */ toString(): string { const path = (this.snapshot.routeConfig && this.snapshot.routeConfig.path) || ''; return `ChildActivationEnd(path: '${path}')`; @@ -574,6 +586,8 @@ export class ActivationStart { /** @docsNotRequired */ public snapshot: ActivatedRouteSnapshot, ) {} + + /** @docs-private */ toString(): string { const path = (this.snapshot.routeConfig && this.snapshot.routeConfig.path) || ''; return `ActivationStart(path: '${path}')`; @@ -595,6 +609,8 @@ export class ActivationEnd { /** @docsNotRequired */ public snapshot: ActivatedRouteSnapshot, ) {} + + /** @docs-private */ toString(): string { const path = (this.snapshot.routeConfig && this.snapshot.routeConfig.path) || ''; return `ActivationEnd(path: '${path}')`; @@ -623,6 +639,7 @@ export class Scroll { readonly scrollBehavior?: 'manual' | 'after-transition', ) {} + /** @docs-private */ toString(): string { const pos = this.position ? `${this.position[0]}, ${this.position[1]}` : null; return `Scroll(anchor: '${this.anchor}', position: '${pos}')`; diff --git a/packages/router/src/models.ts b/packages/router/src/models.ts index b5c1eb518c1e..6ca96e95c405 100644 --- a/packages/router/src/models.ts +++ b/packages/router/src/models.ts @@ -819,7 +819,8 @@ export interface LoadedRouterConfig { * * @Injectable() * class CanActivateTeam implements CanActivate { - * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * private readonly permissions = inject(Permissions); + * private readonly currentUser = inject(UserToken); * * canActivate( * route: ActivatedRouteSnapshot, @@ -937,7 +938,8 @@ export type CanActivateFn = ( * * @Injectable() * class CanActivateTeam implements CanActivateChild { - * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * private readonly permissions = inject(Permissions); + * private readonly currentUser = inject(UserToken); * * canActivateChild( * route: ActivatedRouteSnapshot, @@ -1028,7 +1030,8 @@ export type CanActivateChildFn = ( * ```ts * @Injectable() * class CanDeactivateTeam implements CanDeactivate { - * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * private readonly permissions = inject(Permissions); + * private readonly currentUser = inject(UserToken); * * canDeactivate( * component: TeamComponent, @@ -1111,7 +1114,8 @@ export type CanDeactivateFn = ( * * @Injectable() * class CanMatchTeamSection implements CanMatch { - * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * private readonly permissions = inject(Permissions); + * private readonly currentUser = inject(UserToken); * * canMatch(route: Route, segments: UrlSegment[]): Observable|Promise|boolean { * return this.permissions.canAccess(this.currentUser, route, segments); @@ -1226,7 +1230,7 @@ export type PartialMatchRouteSnapshot = Pick< * ```ts * @Injectable({ providedIn: 'root' }) * export class HeroResolver implements Resolve { - * constructor(private service: HeroService) {} + * private readonly service = inject(HeroService); * * resolve( * route: ActivatedRouteSnapshot, @@ -1267,7 +1271,7 @@ export type PartialMatchRouteSnapshot = Pick< * }) * export class HeroComponent { * - * constructor(private activatedRoute: ActivatedRoute) {} + * private readonly activatedRoute = inject(ActivatedRoute); * * ngOnInit() { * this.activatedRoute.data.subscribe(({ hero }) => { @@ -1442,7 +1446,8 @@ export type ResolveFn = ( * * @Injectable() * class CanLoadTeamSection implements CanLoad { - * constructor(private permissions: Permissions, private currentUser: UserToken) {} + * private readonly permissions = inject(Permissions); + * private readonly currentUser = inject(UserToken); * * canLoad(route: Route, segments: UrlSegment[]): Observable|Promise|boolean { * return this.permissions.canLoadChildren(this.currentUser, route, segments); diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index 38f06947a49c..dfc739595c6f 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -218,6 +218,9 @@ export type RestoredState = { // The `ɵ` prefix is there to reduce the chance of colliding with any existing user properties on // the history state. ɵrouterPageId?: number; + // When `browserUrl` is used, the actual route URL is stored here so that popstate events + // can use it for route matching instead of the displayed browser URL. + ɵrouterUrl?: string; }; /** diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index 4d265cf886b9..ec9a6a1eeb3c 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -798,7 +798,8 @@ export type ExperimentalAutoCleanupInjectorsFeature = * * This feature is opt-in and requires `RouteReuseStrategy.shouldDestroyInjector` to return `true` * for the routes that should be destroyed. If the `RouteReuseStrategy` uses stored handles, it - * should also implement `retrieveStoredHandle` to ensure we don't destroy injectors for handles that will be reattached. + * should also implement `retrieveStoredRouteHandles` to ensure injectors for handles that will be + * reattached are not destroyed. * * @experimental 21.1 */ diff --git a/packages/router/src/recognize.ts b/packages/router/src/recognize.ts index bb1763c25df0..2503c5c5157d 100644 --- a/packages/router/src/recognize.ts +++ b/packages/router/src/recognize.ts @@ -454,6 +454,7 @@ export class Recognizer { consumedSegments, remainingSegments, childConfig, + outlet, ); if (slicedSegments.length === 0 && segmentGroup.hasChildren()) { diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 9f1c755d5743..27788fed3117 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -318,18 +318,27 @@ export class Router { // position for the page. const restoredState = state?.navigationId ? state : null; + // When `browserUrl` was used during the original navigation, the actual route URL + // was stored in history state as `ɵrouterUrl`. Use it for route matching and + // preserve the browser URL as the displayed URL. + const routerUrl = state?.ɵrouterUrl ?? url; + if (state?.ɵrouterUrl) { + extras = {...extras, browserUrl: url}; + } + // Separate to NavigationStart.restoredState, we must also restore the state to // history.state and generate a new navigationId, since it will be overwritten if (state) { const stateCopy = {...state} as Partial; delete stateCopy.navigationId; delete stateCopy.ɵrouterPageId; + delete stateCopy.ɵrouterUrl; if (Object.keys(stateCopy).length !== 0) { extras.state = stateCopy; } } - const urlTree = this.parseUrl(url); + const urlTree = this.parseUrl(routerUrl); this.scheduleNavigation(urlTree, source, restoredState, extras).catch((e) => { if (this.disposed) { return; diff --git a/packages/router/src/router_config.ts b/packages/router/src/router_config.ts index 7dc11f9a0059..beccd88a2dac 100644 --- a/packages/router/src/router_config.ts +++ b/packages/router/src/router_config.ts @@ -197,7 +197,7 @@ export interface InMemoryScrollingOptions { * A set of configuration options for a router module, provided in the * `forRoot()` method. * - * @see {@link /api/router/routerModule#forRoot forRoot} + * @see {@link /api/router/RouterModule#forRoot forRoot} * * * @publicApi diff --git a/packages/router/src/router_scroller.ts b/packages/router/src/router_scroller.ts index 12bec8675835..738555236305 100644 --- a/packages/router/src/router_scroller.ts +++ b/packages/router/src/router_scroller.ts @@ -7,7 +7,16 @@ */ import {ViewportScroller} from '@angular/common'; -import {inject, Injectable, InjectionToken, NgZone, OnDestroy, untracked} from '@angular/core'; +import { + ApplicationRef, + inject, + Injectable, + InjectionToken, + NgZone, + OnDestroy, + untracked, + ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, +} from '@angular/core'; import {Unsubscribable} from 'rxjs'; import { @@ -36,6 +45,8 @@ export class RouterScroller implements OnDestroy { private restoredId = 0; private store: {[key: string]: [number, number]} = {}; + private isHydrating = inject(IS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}) ?? false; + private readonly urlSerializer = inject(UrlSerializer); private readonly zone = inject(NgZone); readonly viewportScroller = inject(ViewportScroller); @@ -51,6 +62,13 @@ export class RouterScroller implements OnDestroy { // Default both options to 'disabled' this.options.scrollPositionRestoration ||= 'disabled'; this.options.anchorScrolling ||= 'disabled'; + if (this.isHydrating) { + inject(ApplicationRef) + .whenStable() + .then(() => { + this.isHydrating = false; + }); + } } init(): void { @@ -111,6 +129,7 @@ export class RouterScroller implements OnDestroy { routerEvent: NavigationEnd | NavigationSkipped, anchor: string | null, ): void { + if (this.isHydrating) return; const scroll = untracked(this.transitions.currentNavigation)?.extras.scroll; this.zone.runOutsideAngular(async () => { // The scroll event needs to be delayed until after change detection. Otherwise, we may diff --git a/packages/router/src/statemanager/navigation_state_manager.ts b/packages/router/src/statemanager/navigation_state_manager.ts index e83ebe19c3e8..4db2ad85ce04 100644 --- a/packages/router/src/statemanager/navigation_state_manager.ts +++ b/packages/router/src/statemanager/navigation_state_manager.ts @@ -268,8 +268,7 @@ export class NavigationStateManager extends StateManager { // Prepare the state to be stored in the NavigationHistoryEntry. const state = { ...transition.extras.state, - // Include router's navigationId for tracking. Required for in-memory scroll restoration - navigationId: transition.id, + ...this.generateNgRouterState(transition), }; const info: NavigationInfo = {ɵrouterInfo: {intercept: true}}; @@ -463,7 +462,12 @@ export class NavigationStateManager extends StateManager { >((resolve) => { // The `precommitHandler` option is not in the standard DOM types yet (interceptOptions as any).precommitHandler = (controller: any) => { - resolve(controller.redirect.bind(controller)); + if (this.navigation.transition?.navigationType === 'traverse') { + // TODO(atscott): Figure out correct behavior for redirecting traversals + resolve(() => {}); + } else { + resolve(controller.redirect.bind(controller)); + } return precommitHandlerPromise; }; }); @@ -484,7 +488,7 @@ export class NavigationStateManager extends StateManager { : 'push'; const state = { ...transition.extras.state, - navigationId: transition.id, + ...this.generateNgRouterState(transition), }; // this might be a path or an actual URL depending on the baseHref const pathOrUrl = this.location.prepareExternalUrl(internalPath); @@ -539,13 +543,19 @@ export class NavigationStateManager extends StateManager { return new URL(routerDestination, eventDestination.origin).href === eventDestination.href; } + private generateNgRouterState(transition: RouterNavigation) { + return { + ...this.routerUrlState(transition), + // Include router's navigationId for tracking. Required for in-memory scroll restoration + navigationId: transition.id, + }; + } + private deferredCommitSupported(event: NavigateEvent): boolean { return ( this.precommitHandlerSupported && // Cannot defer commit if not cancelable by the Navigation API's rules. - event.cancelable && - // Deferring a traversal commit is currently problematic or not fully supported. - event.navigationType !== 'traverse' + event.cancelable ); } } diff --git a/packages/router/src/statemanager/state_manager.ts b/packages/router/src/statemanager/state_manager.ts index 3f9eed8eb404..a8f96f8c8ef7 100644 --- a/packages/router/src/statemanager/state_manager.ts +++ b/packages/router/src/statemanager/state_manager.ts @@ -91,6 +91,15 @@ export abstract class StateManager { return path; } + protected routerUrlState(navigation?: Navigation): { + ɵrouterUrl?: string; + } { + if (navigation?.targetBrowserUrl === undefined || navigation?.finalUrl === undefined) { + return {}; + } + return {ɵrouterUrl: this.urlSerializer.serialize(navigation.finalUrl)}; + } + protected commitTransition({targetRouterState, finalUrl, initialUrl}: Navigation): void { // If we are committing the transition after having a final URL and target state, we're updating // all pieces of the state. Otherwise, we likely skipped the transition (due to URL handling strategy) @@ -226,20 +235,22 @@ export class HistoryStateManager extends StateManager { } } - private setBrowserUrl(path: string, {extras, id}: Navigation) { + private setBrowserUrl(path: string, navigation: Navigation) { + const {extras, id} = navigation; const {replaceUrl, state} = extras; + if (this.location.isCurrentPathEqualTo(path) || !!replaceUrl) { // replacements do not update the target page const currentBrowserPageId = this.browserPageId; const newState = { ...state, - ...this.generateNgRouterState(id, currentBrowserPageId), + ...this.generateNgRouterState(id, currentBrowserPageId, navigation), }; this.location.replaceState(path, '', newState); } else { const newState = { ...state, - ...this.generateNgRouterState(id, this.browserPageId + 1), + ...this.generateNgRouterState(id, this.browserPageId + 1, navigation), }; this.location.go(path, '', newState); } @@ -299,10 +310,15 @@ export class HistoryStateManager extends StateManager { ); } - private generateNgRouterState(navigationId: number, routerPageId: number) { + private generateNgRouterState( + navigationId: number, + routerPageId: number, + navigation?: Navigation, + ) { if (this.canceledNavigationResolution === 'computed') { - return {navigationId, ɵrouterPageId: routerPageId}; + return {navigationId, ɵrouterPageId: routerPageId, ...this.routerUrlState(navigation)}; } - return {navigationId}; + + return {navigationId, ...this.routerUrlState(navigation)}; } } diff --git a/packages/router/src/url_tree.ts b/packages/router/src/url_tree.ts index bb794f20ce5c..00a5ea386141 100644 --- a/packages/router/src/url_tree.ts +++ b/packages/router/src/url_tree.ts @@ -9,9 +9,9 @@ import {computed, Injectable, ɵRuntimeError as RuntimeError, Signal} from '@angular/core'; import {RuntimeErrorCode} from './errors'; +import type {Router} from './router'; import {convertToParamMap, ParamMap, Params, PRIMARY_OUTLET} from './shared'; import {equalArraysOrString, shallowEqual} from './utils/collection'; -import type {Router} from './router'; /** * A set of options which specify how to determine if a `UrlTree` is active, given the `UrlTree` @@ -285,7 +285,7 @@ export class UrlTree { return this._queryParamMap; } - /** @docsNotRequired */ + /** @docs-private */ toString(): string { return DEFAULT_SERIALIZER.serialize(this); } @@ -323,7 +323,7 @@ export class UrlSegmentGroup { return Object.keys(this.children).length; } - /** @docsNotRequired */ + /** @docs-private */ toString(): string { return serializePaths(this); } @@ -372,7 +372,7 @@ export class UrlSegment { return this._parameterMap; } - /** @docsNotRequired */ + /** @docs-private */ toString(): string { return serializePath(this); } @@ -615,7 +615,11 @@ class UrlParser { } parseRootSegment(): UrlSegmentGroup { - this.consumeOptional('/'); + // Consume all leading slashes. Multiple consecutive leading slashes (e.g. `///path`) + // are not meaningful and would otherwise produce a `//path`-style serialized URL, + // which browsers interpret as protocol-relative (resolving to a different origin) + // and reject with a SecurityError when passed to `history.pushState`/`replaceState`. + while (this.consumeOptional('/')) {} if (this.remaining === '' || this.peekStartsWith('?') || this.peekStartsWith('#')) { return new UrlSegmentGroup([], {}); diff --git a/packages/router/src/utils/config_matching.ts b/packages/router/src/utils/config_matching.ts index 99d7dcd18d4e..afd4a4a780e6 100644 --- a/packages/router/src/utils/config_matching.ts +++ b/packages/router/src/utils/config_matching.ts @@ -126,13 +126,14 @@ export function split( consumedSegments: UrlSegment[], slicedSegments: UrlSegment[], config: Route[], + outlet?: string, ): { segmentGroup: UrlSegmentGroup; slicedSegments: UrlSegment[]; } { if ( slicedSegments.length > 0 && - containsEmptyPathMatchesWithNamedOutlets(segmentGroup, slicedSegments, config) + containsEmptyPathMatchesWithNamedOutlets(segmentGroup, slicedSegments, config, outlet) ) { const s = new UrlSegmentGroup( consumedSegments, @@ -195,10 +196,24 @@ function containsEmptyPathMatchesWithNamedOutlets( segmentGroup: UrlSegmentGroup, slicedSegments: UrlSegment[], routes: Route[], + outlet?: string, ): boolean { - return routes.some( - (r) => emptyPathMatch(segmentGroup, slicedSegments, r) && getOutlet(r) !== PRIMARY_OUTLET, - ); + return routes.some((r) => { + // 1. Can this route match as an empty path? + const matchesEmpty = emptyPathMatch(segmentGroup, slicedSegments, r); + if (!matchesEmpty) return false; + + // 2. Is this a named outlet? (We only pull in empty paths if they are named outlets). + const isNamedOutlet = getOutlet(r) !== PRIMARY_OUTLET; + if (!isNamedOutlet) return false; + + // 3. Are we already processing this outlet? If so, we ignore it as a pull-in + // candidate. For example, if we are evaluating the 'secondary' outlet, we shouldn't + // "pull in" an empty 'secondary' group. We should let standard + // segment matching handle it (which looks at the actual characters in the URL). + const isSelfEvaluating = outlet !== undefined && getOutlet(r) === outlet; + return !isSelfEvaluating; + }); } function containsEmptyPathMatches( diff --git a/packages/router/test/computed_state_restoration.spec.ts b/packages/router/test/computed_state_restoration.spec.ts index 2dead2978d4b..f451a6237452 100644 --- a/packages/router/test/computed_state_restoration.spec.ts +++ b/packages/router/test/computed_state_restoration.spec.ts @@ -330,14 +330,17 @@ for (const browserAPI of ['navigation', 'history'] as const) { location.back(); await nextNavigation(); expect(location.path()).toEqual('/unguarded'); - expectPageIndex(2); + // With 'navigation' API, we never commit the transition back to 'second' + // so the "redirect" from the canActivate guard that triggered a new browser + // navigation actually cancels the back traversal from second to first. + expectPageIndex(browserAPI === 'navigation' ? 3 : 2); TestBed.inject(MyCanActivateGuard).redirectTo = null; location.back(); await nextNavigation(); - expect(location.path()).toEqual('/first'); - expectPageIndex(1); + expect(location.path()).toEqual(browserAPI === 'navigation' ? '/second' : '/first'); + expectPageIndex(browserAPI === 'navigation' ? 2 : 1); }); it('restores history correctly when component throws error in constructor and replaceUrl=true', async () => { diff --git a/packages/router/test/integration/integration.spec.ts b/packages/router/test/integration/integration.spec.ts index 8a593c0e12b9..3ec1cbece857 100644 --- a/packages/router/test/integration/integration.spec.ts +++ b/packages/router/test/integration/integration.spec.ts @@ -73,6 +73,7 @@ import {eagerUrlUpdateStrategyIntegrationSuite} from './eager_url_update_strateg import {duplicateInFlightNavigationsIntegrationSuite} from './duplicate_in_flight_navigations.spec'; import {navigationErrorsIntegrationSuite} from './navigation_errors.spec'; import {useAutoTick} from '@angular/private/testing'; +import {RouterTestingHarness} from '../../testing'; for (const browserAPI of ['navigation', 'history'] as const) { describe(`${browserAPI}-based routing`, () => { @@ -393,6 +394,33 @@ for (const browserAPI of ['navigation', 'history'] as const) { expect(event!.restoredState!.navigationId).toEqual(userVictorNavStart.id); }); + it('should restore internal route on popstate when browserUrl is used', async () => { + const router: Router = TestBed.inject(Router); + const location: Location = TestBed.inject(Location); + + router.resetConfig([ + {path: 'home', component: SimpleCmp}, + {path: 'one', component: SimpleCmp}, + ]); + + const harness = await RouterTestingHarness.create('/home'); + router.setUpLocationChangeListener(); + + await router.navigateByUrl('/one', {browserUrl: '/display-one'}); + expect(location.path()).toEqual('/display-one'); + expect(router.url).toEqual('/one'); + + location.back(); + await advance(harness.fixture); + expect(location.path()).toEqual('/home'); + expect(router.url).toEqual('/home'); + + location.forward(); + await advance(harness.fixture); + expect(router.url).toEqual('/one'); + expect(location.path()).toEqual('/display-one'); + }); + it('should navigate to the same url when config changes', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location); diff --git a/packages/router/test/recognize.spec.ts b/packages/router/test/recognize.spec.ts index 4b5a68083b0c..8b283cb8e318 100644 --- a/packages/router/test/recognize.spec.ts +++ b/packages/router/test/recognize.spec.ts @@ -578,6 +578,71 @@ describe('recognize', () => { await expectAsync(recognizePromise).toBeRejected(); }); }); + + describe('nested empty paths with outlets (issue 67708)', () => { + it('should match nested primary child regardless of named outlet empty path sibling', async () => { + const config = [ + { + path: '', + component: ComponentA, + children: [ + { + path: '', + component: ComponentB, + children: [{path: 'component', component: ComponentC}], + }, + { + path: '', + outlet: 'secondary', + component: ComponentD, + children: [{path: 'component-copy', component: ComponentE}], + }, + ], + }, + ]; + + const s = await recognize(config, 'component'); + checkActivatedRoute(s.root.firstChild!, '', {}, ComponentA); + const c = s.root.firstChild!.children; + // Should find primary child + checkActivatedRoute(c[0], '', {}, ComponentB, PRIMARY_OUTLET); + checkActivatedRoute(c[0].firstChild!, 'component', {}, ComponentC); + }); + + it('should match named outlet child when navigating to it via secondary URL', async () => { + const config = [ + { + path: '', + component: ComponentA, + children: [ + { + path: '', + component: ComponentB, + children: [{path: 'component', component: ComponentC}], + }, + { + path: '', + outlet: 'secondary', + component: ComponentD, + children: [{path: 'component-copy', component: ComponentA}], + }, + ], + }, + ]; + + const s = await recognize(config, '(secondary:component-copy)'); + checkActivatedRoute(s.root.firstChild!, '', {}, ComponentA); + const c = s.root.firstChild!.children; + const primaryRoute = c.find((r: any) => r.outlet === PRIMARY_OUTLET); + expect(primaryRoute).toBeDefined(); + checkActivatedRoute(primaryRoute!, '', {}, ComponentB, PRIMARY_OUTLET); + + const secondaryRoute = c.find((r: any) => r.outlet === 'secondary'); + expect(secondaryRoute).toBeDefined(); + checkActivatedRoute(secondaryRoute!, '', {}, ComponentD, 'secondary'); + checkActivatedRoute(secondaryRoute!.firstChild!, 'component-copy', {}, ComponentA); + }); + }); }); describe('wildcards', () => { diff --git a/packages/router/test/router_scroller.spec.ts b/packages/router/test/router_scroller.spec.ts index 7b6e52a81c11..76f8d58a8ebb 100644 --- a/packages/router/test/router_scroller.spec.ts +++ b/packages/router/test/router_scroller.spec.ts @@ -21,7 +21,11 @@ import {filter, switchMap, take} from 'rxjs/operators'; import {PrivateRouterEvents, Scroll} from '../src/events'; import {ROUTER_SCROLLER, RouterScroller} from '../src/router_scroller'; -import {ɵWritable as Writable} from '@angular/core'; +import { + ApplicationRef, + ɵWritable as Writable, + ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, +} from '@angular/core'; import {ViewportScroller} from '@angular/common'; import {NavigationTransitions} from '../src/navigation_transition'; import {timeout} from '@angular/private/testing'; @@ -151,6 +155,79 @@ describe('RouterScroller', () => { }); }); + describe('SSR hydration', () => { + it('should not scroll to top on initial navigation when hydrating', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'enabled', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Simulate the initial navigation that happens during SSR hydration. + // The user may have already scrolled down on the server-rendered page, + // so the scroller must not reset the position to [0, 0]. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + }); + + it('should not scroll to top on initial navigation when hydrating (top mode)', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + }); + + it('should scroll to top on subsequent navigations after hydration', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Skip the initial navigation — no scroll event is emitted during hydration. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + // A subsequent navigation should still scroll to top as normal. + events.next(new NavigationStart(2, '/b')); + events.next(new NavigationEnd(2, '/b', '/b')); + await nextScrollEvent(events); + + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + + it('should not scroll on immediate follow-up navigations triggered during hydration', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Fire both navigations during hydration — no scroll events are emitted for either. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + events.next(new NavigationStart(2, '/a')); + events.next(new NavigationEnd(2, '/a?filter=active', '/a?filter=active')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + + // A navigation after hydration settles should scroll normally. + events.next(new NavigationStart(3, '/b')); + events.next(new NavigationEnd(3, '/b', '/b')); + await nextScrollEvent(events); + + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + }); + describe('extending a scroll service', () => { it('work', async () => { const {events, viewportScroller} = createRouterScroller({ @@ -262,13 +339,20 @@ describe('RouterScroller', () => { }); }); -function createRouterScroller({ - scrollPositionRestoration, - anchorScrolling, -}: { - scrollPositionRestoration: 'disabled' | 'enabled' | 'top'; - anchorScrolling: 'disabled' | 'enabled'; -}) { +function createRouterScroller( + { + scrollPositionRestoration, + anchorScrolling, + }: { + scrollPositionRestoration: 'disabled' | 'enabled' | 'top'; + anchorScrolling: 'disabled' | 'enabled'; + }, + extraProviders: any[] = [], +) { + if (extraProviders.length > 0) { + TestBed.configureTestingModule({providers: extraProviders}); + } + const events = new Subject(); (TestBed.inject(NavigationTransitions) as Writable).events = events; diff --git a/packages/router/test/url_serializer.spec.ts b/packages/router/test/url_serializer.spec.ts index 5f467d00bee6..9258edfae2df 100644 --- a/packages/router/test/url_serializer.spec.ts +++ b/packages/router/test/url_serializer.spec.ts @@ -417,6 +417,25 @@ describe('url serializer', () => { }); }); + describe('multiple leading slashes', () => { + // Regression test: https://github.com/angular/angular/issues/66233 + // `///path` was parsed into an empty UrlSegment followed by `path`, which the serializer + // rendered as `//path` — a protocol-relative URL that browsers reject with a SecurityError + // when passed to history.pushState/replaceState. + it('should normalize multiple leading slashes when parsing', () => { + expect(url.serialize(url.parse('///test'))).toEqual('/test'); + }); + + it('should normalize any number of leading slashes when parsing', () => { + expect(url.serialize(url.parse('////test'))).toEqual('/test'); + expect(url.serialize(url.parse('/////test'))).toEqual('/test'); + }); + + it('should preserve query params and fragments after normalizing leading slashes', () => { + expect(url.serialize(url.parse('///test?foo=bar#frag'))).toEqual('/test?foo=bar#frag'); + }); + }); + describe('error handling', () => { it('should throw when invalid characters inside children', () => { expect(() => url.parse('/one/(left#one)')).toThrowError(); diff --git a/packages/router/test/with_platform_navigation.spec.ts b/packages/router/test/with_platform_navigation.spec.ts index 98a265c032fc..7fdacc516436 100644 --- a/packages/router/test/with_platform_navigation.spec.ts +++ b/packages/router/test/with_platform_navigation.spec.ts @@ -26,6 +26,14 @@ import {inject} from '@angular/core'; /// +function isFirefox() { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('firefox') != -1) { + return true; + } + return false; +} + describe('withPlatformNavigation feature', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -230,7 +238,7 @@ describe('configuration error', () => { }); }); -if (typeof window !== 'undefined' && 'navigation' in window) { +if (typeof window !== 'undefined' && 'navigation' in window && !isFirefox()) { describe('real platform navigation', () => { const navigation = window.navigation as Navigation; beforeEach(() => { diff --git a/packages/router/testing/src/router_testing_harness.ts b/packages/router/testing/src/router_testing_harness.ts index 2fbaabda2f32..27bdc9053a11 100644 --- a/packages/router/testing/src/router_testing_harness.ts +++ b/packages/router/testing/src/router_testing_harness.ts @@ -7,6 +7,7 @@ */ import { + ChangeDetectionStrategy, Component, DebugElement, Injectable, @@ -44,6 +45,7 @@ export class RootFixtureService { @Component({ template: '', imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.Eager, }) export class RootCmp { @ViewChild(RouterOutlet) outlet?: RouterOutlet; diff --git a/packages/router/upgrade/PACKAGE.md b/packages/router/upgrade/PACKAGE.md index 9cc669298760..ded040e182ee 100644 --- a/packages/router/upgrade/PACKAGE.md +++ b/packages/router/upgrade/PACKAGE.md @@ -1 +1 @@ -Provides support for upgrading routing applications from Angular JS to Angular. +Provides support for upgrading routing applications from AngularJS to Angular. diff --git a/packages/service-worker/worker/src/adapter.ts b/packages/service-worker/worker/src/adapter.ts index 63505b1691c1..8b7aa256a6b3 100644 --- a/packages/service-worker/worker/src/adapter.ts +++ b/packages/service-worker/worker/src/adapter.ts @@ -51,7 +51,7 @@ export class Adapter { /** * Wrapper around the `Headers` constructor. */ - newHeaders(headers: {[name: string]: string}): Headers { + newHeaders(headers: HeadersInit): Headers { return new Headers(headers); } diff --git a/packages/service-worker/worker/src/assets.ts b/packages/service-worker/worker/src/assets.ts index b3d220eb0bcc..cec901050b97 100644 --- a/packages/service-worker/worker/src/assets.ts +++ b/packages/service-worker/worker/src/assets.ts @@ -501,18 +501,49 @@ export abstract class AssetGroup { * Create a new `Request` based on the specified URL and `RequestInit` options, preserving only * metadata that are known to be safe. * - * Currently, only headers are preserved. + * Currently, headers, redirect policy, an explicit `credentials: 'omit'`, and the HTTP cache + * mode are preserved. On cross-origin redirects, sensitive headers are removed. This includes + * `Authorization`, as required by the Fetch redirect algorithm, and forbidden request headers + * that could contain credentials. * * NOTE: - * Things like credential inclusion are intentionally omitted to avoid issues with opaque - * responses. - * + * `credentials: 'same-origin'` and `credentials: 'include'` are intentionally not preserved. + * Forwarding `'include'` could leak cookies to cross-origin asset hosts, and forwarding + * `'same-origin'` matches the default `fetch()` behavior so there is nothing to preserve. + * Requests with `cache: 'only-if-cached'` and `mode !== 'same-origin'` are short-circuited + * earlier in `Driver.onFetch()` (they are a known Chrome DevTools quirk), so no special + * handling for that combination is needed here. * TODO(gkalpak): * Investigate preserving more metadata. See, also, discussion on preserving `mode`: - * https://github.com/angular/angular/issues/41931#issuecomment-1227601347 + * https://github.com/angular/angular/issues/41931#issuecomment-1227601347. */ - private newRequestWithMetadata(url: string, options: RequestInit): Request { - return this.adapter.newRequest(url, {headers: options.headers}); + private newRequestWithMetadata(url: string, options: Request): Request { + let headers = options.headers; + const parsedUrl = this.adapter.parseUrl(url, this.adapter.origin); + + const hasHeaders = headers.keys().next().done !== true; + + if (hasHeaders && parsedUrl.origin !== this.adapter.origin) { + headers = this.adapter.newHeaders(options.headers); + headers.delete('Authorization'); + headers.delete('Proxy-Authorization'); + headers.delete('Cookie'); + } + + const init: RequestInit = { + headers, + redirect: options.redirect, + }; + + if (options.credentials === 'omit') { + init.credentials = 'omit'; + } + + if (options.cache !== undefined) { + init.cache = options.cache; + } + + return this.adapter.newRequest(url, init); } /** diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index d469db1da2ae..df17e66505b5 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -355,7 +355,7 @@ export class Driver implements Debuggable, UpdateSource { event.waitUntil(this.handlePushSubscriptionChange(event)); } - private onMessageError(event: ExtendableMessageEvent): void { + private onMessageError(event: MessageEvent): void { // Handle message deserialization errors that occur when receiving messages // that cannot be deserialized, typically due to corrupted data or unsupported formats. this.debugger.log( diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 023c5188c6ef..2af08b1b8be5 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -135,7 +135,7 @@ import {envIsSupported} from '../testing/utils'; name: 'other', installMode: 'lazy', updateMode: 'lazy', - urls: ['/baz.txt', '/qux.txt', '/lazy/redirected.txt'], + urls: ['/baz.txt', '/qux.txt', '/lazy/redirected.txt', '/lazy/cross-origin-redirected.txt'], patterns: [], cacheQueryOptions: {ignoreVary: true}, }, @@ -220,6 +220,11 @@ import {envIsSupported} from '../testing/utils'; .withStaticFiles(dist) .withRedirect('/redirected.txt', '/redirect-target.txt') .withRedirect('/lazy/redirected.txt', '/lazy/redirect-target.txt') + .withRedirect( + '/lazy/cross-origin-redirected.txt', + 'https://example.com/lazy/redirect-target.txt', + ) + .withRedirect('https://example.com/lazy/redirect-target.txt', '/lazy/redirect-target.txt') .withError('/error.txt'); const server = serverBuilderBase.withManifest(manifest).build(); @@ -1655,12 +1660,39 @@ import {envIsSupported} from '../testing/utils'; expect((bazReq as any).unknownOption).toBeUndefined(); }); + it(`passes 'credentials: omit' through to the server`, async () => { + // Request a lazy-cached asset (so that it is fetched from the network) and provide an + // explicit anonymous credentials mode. + const reqInit = {credentials: 'omit'}; + expect(await makeRequest(scope, '/baz.txt', undefined, reqInit)).toBe('this is baz'); + + // Verify that the explicit `'omit'` value was preserved (instead of being replaced by the + // default `'same-origin'`). + const [bazReq] = server.getRequestsFor('/baz.txt'); + expect(bazReq.credentials).toBe('omit'); + }); + + it(`passes 'cache' through to the server`, async () => { + // Request a lazy-cached asset (so that it is fetched from the network) and provide an + // explicit HTTP cache mode. + const reqInit = {cache: 'no-store'}; + expect(await makeRequest(scope, '/baz.txt', undefined, reqInit)).toBe('this is baz'); + + // Verify that the explicit `cache` value was preserved (instead of being replaced by the + // default `'default'`). + const [bazReq] = server.getRequestsFor('/baz.txt'); + expect(bazReq.cache).toBe('no-store'); + }); + describe('for redirect requests', () => { it('passes headers through to the server', async () => { // Request a redirected, lazy-cached asset (so that it is fetched from the network) and // provide headers. const reqInit = { - headers: {SomeHeader: 'SomeValue'}, + headers: { + Authorization: 'Bearer secret', + SomeHeader: 'SomeValue', + }, }; expect(await makeRequest(scope, '/lazy/redirected.txt', undefined, reqInit)).toBe( 'this was a redirect too', @@ -1668,6 +1700,29 @@ import {envIsSupported} from '../testing/utils'; // Verify that the headers were passed through to the network. const [redirectReq] = server.getRequestsFor('/lazy/redirect-target.txt'); + expect(redirectReq.headers.get('Authorization')).toBe('Bearer secret'); + expect(redirectReq.headers.get('SomeHeader')).toBe('SomeValue'); + }); + + it('does not pass sensitive headers through to a different origin', async () => { + const reqInit = { + headers: { + Authorization: 'Bearer secret', + Cookie: 'session=secret', + 'Proxy-Authorization': 'Basic secret', + SomeHeader: 'SomeValue', + }, + }; + expect( + await makeRequest(scope, '/lazy/cross-origin-redirected.txt', undefined, reqInit), + ).toBe('this was a redirect too'); + + const [redirectReq] = server.getRequestsFor( + 'https://example.com/lazy/redirect-target.txt', + ); + expect(redirectReq.headers.get('Authorization')).toBeNull(); + expect(redirectReq.headers.get('Cookie')).toBeNull(); + expect(redirectReq.headers.get('Proxy-Authorization')).toBeNull(); expect(redirectReq.headers.get('SomeHeader')).toBe('SomeValue'); }); @@ -1689,6 +1744,40 @@ import {envIsSupported} from '../testing/utils'; expect(redirectReq.mode).toBe('cors'); // The default value. expect((redirectReq as any).unknownOption).toBeUndefined(); }); + + it('does not follow redirects when redirect policy is error', async () => { + await expectAsync( + makeRequest(scope, '/lazy/redirected.txt', undefined, {redirect: 'error'}), + ).toBeRejected(); + }); + + it(`passes 'credentials: omit' through to the server`, async () => { + // Request a redirected, lazy-cached asset (so that it is fetched from the network) and + // provide an explicit anonymous credentials mode. + const reqInit = {credentials: 'omit'}; + expect(await makeRequest(scope, '/lazy/redirected.txt', undefined, reqInit)).toBe( + 'this was a redirect too', + ); + + // Verify that the explicit `'omit'` value was preserved across the redirect + // reconstruction (instead of being replaced by the default `'same-origin'`). + const [redirectReq] = server.getRequestsFor('/lazy/redirect-target.txt'); + expect(redirectReq.credentials).toBe('omit'); + }); + + it(`passes 'cache' through to the server`, async () => { + // Request a redirected, lazy-cached asset (so that it is fetched from the network) and + // provide an explicit HTTP cache mode. + const reqInit = {cache: 'no-store'}; + expect(await makeRequest(scope, '/lazy/redirected.txt', undefined, reqInit)).toBe( + 'this was a redirect too', + ); + + // Verify that the explicit `cache` value was preserved across the redirect + // reconstruction (instead of being replaced by the default `'default'`). + const [redirectReq] = server.getRequestsFor('/lazy/redirect-target.txt'); + expect(redirectReq.cache).toBe('no-store'); + }); }); }); diff --git a/packages/service-worker/worker/testing/fetch.ts b/packages/service-worker/worker/testing/fetch.ts index b103cfb11c2c..3e5997ba744c 100644 --- a/packages/service-worker/worker/testing/fetch.ts +++ b/packages/service-worker/worker/testing/fetch.ts @@ -59,6 +59,20 @@ export class MockBody implements Body { export class MockHeaders implements Headers { map = new Map(); + constructor(headers?: HeadersInit) { + if (headers === undefined) { + return; + } + + if (Array.isArray(headers)) { + headers.forEach(([name, value]) => this.set(name, value)); + } else if (headers instanceof MockHeaders || headers instanceof Headers) { + headers.forEach((value, name) => this.set(name, value)); + } else { + Object.entries(headers).forEach(([name, value]) => this.set(name, value)); + } + } + [Symbol.iterator]() { return this.map[Symbol.iterator](); } @@ -115,7 +129,7 @@ export class MockRequest extends MockBody implements Request { readonly keepalive: boolean = true; readonly method: string = 'GET'; readonly mode: RequestMode = 'cors'; - readonly redirect: RequestRedirect = 'error'; + readonly redirect: RequestRedirect = 'follow'; readonly referrer: string = ''; readonly referrerPolicy: ReferrerPolicy = 'no-referrer'; readonly signal: AbortSignal = null as any; @@ -131,15 +145,8 @@ export class MockRequest extends MockBody implements Request { throw 'Not implemented'; } this.url = input; - const headers = init.headers as {[key: string]: string}; - if (headers !== undefined) { - if (headers instanceof MockHeaders) { - this.headers = headers; - } else { - Object.keys(headers).forEach((header) => { - this.headers.set(header, headers[header]); - }); - } + if (init.headers !== undefined) { + this.headers = new MockHeaders(init.headers); } if (init.cache !== undefined) { this.cache = init.cache; @@ -153,6 +160,9 @@ export class MockRequest extends MockBody implements Request { if (init.method !== undefined) { this.method = init.method; } + if (init.redirect !== undefined) { + this.redirect = init.redirect; + } if (init.destination !== undefined) { this.destination = init.destination; } @@ -164,9 +174,11 @@ export class MockRequest extends MockBody implements Request { } return new MockRequest(this.url, { body: this._body, + cache: this.cache, mode: this.mode, credentials: this.credentials, headers: this.headers, + redirect: this.redirect, }); } } @@ -190,15 +202,8 @@ export class MockResponse extends MockBody implements Response { super(typeof body === 'string' ? body : null); this.status = init.status !== undefined ? init.status : 200; this.statusText = init.statusText !== undefined ? init.statusText : 'OK'; - const headers = init.headers as {[key: string]: string}; - if (headers !== undefined) { - if (headers instanceof MockHeaders) { - this.headers = headers; - } else { - Object.keys(headers).forEach((header) => { - this.headers.set(header, headers[header]); - }); - } + if (init.headers !== undefined) { + this.headers = new MockHeaders(init.headers); } if (init.type !== undefined) { this.type = init.type; diff --git a/packages/service-worker/worker/testing/mock.ts b/packages/service-worker/worker/testing/mock.ts index d3ae6c682797..f16d0ec2913c 100644 --- a/packages/service-worker/worker/testing/mock.ts +++ b/packages/service-worker/worker/testing/mock.ts @@ -165,7 +165,11 @@ export class MockServerState { } const url = req.url.split('?')[0]; if (this.resources.has(url)) { - return this.resources.get(url)!.clone(); + const response = this.resources.get(url)!.clone(); + if (response.redirected && req.redirect === 'error') { + throw new Error('Redirect disallowed by request policy.'); + } + return response; } if (this.errors.has(url)) { throw new Error('Intentional failure!'); diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index ec8ea7c51df4..5ce267607596 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -181,11 +181,8 @@ export class SwTestHarnessImpl return new MockResponse(body, init); } - override newHeaders(headers: {[name: string]: string}): Headers { - return Object.keys(headers).reduce((mock, name) => { - mock.set(name, headers[name]); - return mock; - }, new MockHeaders()); + override newHeaders(headers: HeadersInit): Headers { + return new MockHeaders(headers); } async skipWaiting(): Promise { diff --git a/packages/upgrade/PACKAGE.md b/packages/upgrade/PACKAGE.md index 5db9d762e69c..cc10336a7b98 100644 --- a/packages/upgrade/PACKAGE.md +++ b/packages/upgrade/PACKAGE.md @@ -1,4 +1,4 @@ -Provides support for upgrading applications from Angular JS to Angular. +Provides support for upgrading applications from AngularJS to Angular. The primary entry point is deprecated. Use the secondary entry point, `upgrade/static`. diff --git a/packages/upgrade/src/common/test/helpers/common_test_helpers.ts b/packages/upgrade/src/common/test/helpers/common_test_helpers.ts index 167e32722690..9e803fd0d3d4 100644 --- a/packages/upgrade/src/common/test/helpers/common_test_helpers.ts +++ b/packages/upgrade/src/common/test/helpers/common_test_helpers.ts @@ -31,10 +31,11 @@ const ng1Versions = [ }, ]; +let counter = 0; export function createWithEachNg1VersionFn(setNg1: typeof setAngularJSGlobal) { return (specSuite: () => void) => ng1Versions.forEach(({label, files}) => { - describe(`[AngularJS v${label}]`, () => { + describe(`[AngularJS v${label}] ${counter++}`, () => { // Problem: // As soon as `angular-mocks.js` is loaded, it runs `beforeEach` and `afterEach` to register // setup/tear down callbacks. Jasmine 2.9+ does not allow `beforeEach`/`afterEach` to be diff --git a/packages/zone.js/CHANGELOG.md b/packages/zone.js/CHANGELOG.md index 72759354aefd..560f3152ad1d 100644 --- a/packages/zone.js/CHANGELOG.md +++ b/packages/zone.js/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.16.1 (2026-02-18) + +* fix(zone.js): support passthrough of Promise.try API ([fc557f0](https://github.com/angular/angular/commit/fc557f0)), closes [#67057](https://github.com/angular/angular/issues/67057) + + ## 0.16.0 (2025-11-19) - fix(zone.js): Support jasmine v6 ([48abe00](https://github.com/angular/angular/commit/48abe00)) diff --git a/packages/zone.js/lib/common/promise.ts b/packages/zone.js/lib/common/promise.ts index 7eca3b299580..dda37fd94218 100644 --- a/packages/zone.js/lib/common/promise.ts +++ b/packages/zone.js/lib/common/promise.ts @@ -34,7 +34,7 @@ export function patchPromise(Zone: ZoneType): void { api.onUnhandledError = (e: any) => { if (api.showUncaughtError()) { const rejection = e && e.rejection; - if (rejection) { + if (rejection && e.zone && e.task) { console.error( 'Unhandled Promise rejection:', rejection instanceof Error ? rejection.message : rejection, diff --git a/packages/zone.js/lib/zone-impl.ts b/packages/zone.js/lib/zone-impl.ts index 04a914d4ef9a..f0e01ee2c0ec 100644 --- a/packages/zone.js/lib/zone-impl.ts +++ b/packages/zone.js/lib/zone-impl.ts @@ -762,10 +762,12 @@ export type AmbientZone = Zone; const global = globalThis as any; -// __Zone_symbol_prefix global can be used to override the default zone -// symbol prefix with a custom one if needed. +// __Zone_symbol_prefix can be set globally to override the default zone symbol prefix. export function __symbol__(name: string) { - const symbolPrefix = global['__Zone_symbol_prefix'] || '__zone_symbol__'; + const rawPrefix = global['__Zone_symbol_prefix']; + // Guard against DOM clobbering: an attacker can set __Zone_symbol_prefix to an HTMLElement + // via e.g. , so we only trust it if it's actually a string. + const symbolPrefix = typeof rawPrefix === 'string' ? rawPrefix : '__zone_symbol__'; return symbolPrefix + name; } @@ -1131,8 +1133,6 @@ export function initZone(): ZoneType { 'eventTask': 0, }; - private _parentDelegate: _ZoneDelegate | null; - private _forkDlgt: _ZoneDelegate | null; private _forkZS: ZoneSpec | null; private _forkCurrZone: Zone | null; @@ -1168,7 +1168,6 @@ export function initZone(): ZoneType { constructor(zone: Zone, parentDelegate: _ZoneDelegate | null, zoneSpec: ZoneSpec | null) { this._zone = zone as ZoneImpl; - this._parentDelegate = parentDelegate; this._forkZS = zoneSpec && (zoneSpec && zoneSpec.onFork ? zoneSpec : parentDelegate!._forkZS); this._forkDlgt = zoneSpec && (zoneSpec.onFork ? parentDelegate : parentDelegate!._forkDlgt); @@ -1438,10 +1437,13 @@ export function initZone(): ZoneType { task.runCount++; return task.zone.runTask(task, target, args); } finally { - if (_numberOfNestedTaskFrames == 1) { - drainMicroTaskQueue(); + try { + if (_numberOfNestedTaskFrames === 1 && !global[enableNativeMicrotaskDraining]) { + drainMicroTaskQueueSynchronously(); + } + } finally { + _numberOfNestedTaskFrames--; } - _numberOfNestedTaskFrames--; } } @@ -1503,47 +1505,63 @@ export function initZone(): ZoneType { const symbolSetTimeout = __symbol__('setTimeout'); const symbolPromise = __symbol__('Promise'); const symbolThen = __symbol__('then'); + // To prevent any breaking changes resulting from this change, given that + // it was already causing a significant number of failures in g3, we have hidden + // that behavior behind a global configuration flag. Consumers can enable this + // flag explicitly if they want the microtask queue to be drained as defined + // in the specification. + const enableNativeMicrotaskDraining = __symbol__('enable_native_microtask_draining'); let _microTaskQueue: Task[] = []; - let _isDrainingMicrotaskQueue: boolean = false; + let _isDrainingMicrotaskQueue = false; let nativeMicroTaskQueuePromise: any; function nativeScheduleMicroTask(func: Function) { - if (!nativeMicroTaskQueuePromise) { - if (global[symbolPromise]) { - nativeMicroTaskQueuePromise = global[symbolPromise].resolve(0); - } + if (!nativeMicroTaskQueuePromise && global[symbolPromise]) { + nativeMicroTaskQueuePromise = global[symbolPromise].resolve(0); } + if (nativeMicroTaskQueuePromise) { - let nativeThen = nativeMicroTaskQueuePromise[symbolThen]; - if (!nativeThen) { - // native Promise is not patchable, we need to use `then` directly - // issue 1078 - nativeThen = nativeMicroTaskQueuePromise['then']; - } - nativeThen.call(nativeMicroTaskQueuePromise, func); + const thenFn = nativeMicroTaskQueuePromise[symbolThen] ?? nativeMicroTaskQueuePromise['then']; // fallback for non-patchable Promise + // Use the resolved native promise to schedule the microtask + thenFn.call(nativeMicroTaskQueuePromise, func); } else { + // Fallback to setTimeout if native promise is unavailable global[symbolSetTimeout](func, 0); } } function scheduleMicroTask(task?: MicroTask) { - // if we are not running in any task, and there has not been anything scheduled - // we must bootstrap the initial task creation by manually scheduling the drain - if (_numberOfNestedTaskFrames === 0 && _microTaskQueue.length === 0) { - // We are not running in Task, so we need to kickstart the microtask queue. - nativeScheduleMicroTask(drainMicroTaskQueue); + const isNativeDrainingEnabled = global[enableNativeMicrotaskDraining]; + const shouldDrainWithNative = + isNativeDrainingEnabled && _microTaskQueue.length === 0 && !_isDrainingMicrotaskQueue; + const shouldDrainWithoutNative = + !isNativeDrainingEnabled && _numberOfNestedTaskFrames === 0 && _microTaskQueue.length === 0; + + if (shouldDrainWithNative || shouldDrainWithoutNative) { + // Start draining the microtask queue if: + // - Native draining is enabled and not currently in progress, or + // - Native draining is disabled, and there are no nested tasks and no queued microtasks. + nativeScheduleMicroTask(drainMicroTaskQueueSynchronously); + } + + if (task) { + _microTaskQueue.push(task); } - task && _microTaskQueue.push(task); } - function drainMicroTaskQueue() { - if (!_isDrainingMicrotaskQueue) { - _isDrainingMicrotaskQueue = true; + function drainMicroTaskQueueSynchronously() { + if (_isDrainingMicrotaskQueue) { + return; + } + + _isDrainingMicrotaskQueue = true; + + try { while (_microTaskQueue.length) { const queue = _microTaskQueue; _microTaskQueue = []; - for (let i = 0; i < queue.length; i++) { - const task = queue[i]; + + for (const task of queue) { try { task.zone.runTask(task, null, null); } catch (error) { @@ -1551,8 +1569,18 @@ export function initZone(): ZoneType { } } } - _api.microtaskDrainDone(); - _isDrainingMicrotaskQueue = false; + } finally { + // The order matters! + if (global[enableNativeMicrotaskDraining]) { + _isDrainingMicrotaskQueue = false; + _api.microtaskDrainDone(); + } else { + try { + _api.microtaskDrainDone(); + } finally { + _isDrainingMicrotaskQueue = false; + } + } } } @@ -1579,7 +1607,7 @@ export function initZone(): ZoneType { currentZoneFrame: () => _currentZoneFrame, onUnhandledError: noop, microtaskDrainDone: noop, - scheduleMicroTask: scheduleMicroTask, + scheduleMicroTask, showUncaughtError: () => !(ZoneImpl as any)[__symbol__('ignoreConsoleErrorUncaughtError')], patchEventTarget: () => [], patchOnProperties: noop, @@ -1599,7 +1627,7 @@ export function initZone(): ZoneType { attachOriginToPatched: () => noop, _redefineProperty: () => noop, patchCallbacks: () => noop, - nativeScheduleMicroTask: nativeScheduleMicroTask, + nativeScheduleMicroTask, }; let _currentZoneFrame: ZoneFrame = {parent: null, zone: new ZoneImpl(null, null)}; let _currentTask: Task | null = null; diff --git a/packages/zone.js/lib/zone.configurations.api.ts b/packages/zone.js/lib/zone.configurations.api.ts index 21a18b297934..d68589d5c4c6 100644 --- a/packages/zone.js/lib/zone.configurations.api.ts +++ b/packages/zone.js/lib/zone.configurations.api.ts @@ -555,6 +555,59 @@ declare global { * the user with a string returned from the event handler. */ __zone_symbol__enable_beforeunload?: boolean; + + /** + * https://github.com/angular/angular/issues/41506 + * https://github.com/angular/angular/issues/44446 + * + * By default, `zone.js` maintains a microtask queue manually, which means the microtask + * queue is drained whenever `zone.js` decides to do so under certain circumstances. + * Typically, `zone.js` invokes a task (e.g., an event task) and, after invoking the task, + * checks whether the number of nested task frames is equal to 1 before calling the microtask + * queue draining. + * As thus, there are cases when the microtask queue may be drained synchronously after an + * event task is invoked (if it’s the very first task in the call stack). + * Tasks may actually schedule other tasks, thereby incrementing the stack frame. + * In that case, the microtask queue might be drained after the last task is invoked. + * + * Given that code: + * ```js + * Zone.current.fork({name: 'child'}).run(() => { + * const div = document.createElement('div'); + * div.style.height = '200px'; + * div.style.width = '200px'; + * div.style.backgroundColor = 'red'; + * document.body.appendChild(div); + * + * function listener() { + * Promise.resolve().then(() => { + * div.style.height = '400px'; + * }); + * } + * + * div.addEventListener('fakeEvent', listener); + * div.dispatchEvent(new Event('fakeEvent')); + * console.log(div.getBoundingClientRect().height); // 400 + * }); + * ``` + * + * We would assume that "200" would be logged. However, with `zone.js`, "400" will + * be logged first because it drains the microtask queue too early, as the `fakeEvent` + * event task is the very top task on the stack. + * + * https://promisesaplus.com/#the-then-method + * According to the spec: `onFulfilled` or `onRejected` must not be called until the + * execution context stack contains only platform code. + * + * You may consider enabling the flag below. This will ensure that microtask draining + * does not happen synchronously and always occurs within a browser microtask. + * + * This is critically important for our code and other third-party code, which is + * beyond our control, to work properly. If a microtask is scheduled within an event + * listener to be executed "later", it should indeed be executed later and not synchronously, + * as this would break the expected flow of code execution. + */ + __zone_symbol__enable_native_microtask_draining?: boolean; } /** diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index a8895c4e33cb..a935fb1afe0e 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -1,6 +1,6 @@ { "name": "zone.js", - "version": "0.16.0", + "version": "0.16.1", "description": "Zones for JavaScript", "main": "./bundles/zone.umd.js", "module": "./fesm2015/zone.js", @@ -15,16 +15,16 @@ "@types/node": "^24.3.0", "@types/systemjs": "6.15.4", "bluebird": "3.7.2", - "domino": "https://github.com/angular/domino.git#93e720f143d0296dd2726ffbcf4fc12283363a7b", + "domino": "https://github.com/angular/domino.git#f74cccd496283b6141fbdbeb25b168f4b1c9836a", "esbuild-plugin-umd-wrapper": "3.0.0", "jest-environment-jsdom": "30.2.0", "jest-environment-node": "30.2.0", "jest": "30.2.0", "mocha": "11.7.5", "mock-require": "3.0.3", - "jasmine": "6.0.0", + "jasmine": "6.1.0", "source-map-support": "0.5.21", - "jasmine-core": "6.0.0", + "jasmine-core": "6.1.0", "jasmine-reporters": "2.5.2", "rxjs": "7.8.2", "systemjs": "6.15.1", diff --git a/packages/zone.js/test/browser/browser.spec.ts b/packages/zone.js/test/browser/browser.spec.ts index c66f440a71ce..d5e0eca9247c 100644 --- a/packages/zone.js/test/browser/browser.spec.ts +++ b/packages/zone.js/test/browser/browser.spec.ts @@ -78,7 +78,7 @@ class TestEventListener { removeEventListener(eventName: string, listener: any, options: any) {} } -describe('Zone', function () { +describe('Zone Browser', function () { const rootZone = Zone.current; (Zone as any)[zoneSymbol('ignoreConsoleErrorUncaughtError')] = true; @@ -1107,27 +1107,6 @@ describe('Zone', function () { cancelSpy = jasmine.createSpy('cancel'); }); - it('should handle child event when addEventListener with capture true', () => { - // test capture true - zone.run(function () { - (document as any).addEventListener('click', docListener, {capture: true}); - button.addEventListener('click', btnListener); - }); - - button.dispatchEvent(clickEvent); - expect(hookSpy).toHaveBeenCalled(); - - expect(logs).toEqual(['document', 'button']); - logs = []; - - (document as any).removeEventListener('click', docListener, {capture: true}); - button.removeEventListener('click', btnListener); - expect(cancelSpy).toHaveBeenCalled(); - - button.dispatchEvent(clickEvent); - expect(logs).toEqual([]); - }); - it('should handle child event when addEventListener with capture true', () => { // test capture false zone.run(function () { diff --git a/packages/zone.js/test/common/Promise.spec.ts b/packages/zone.js/test/common/Promise.spec.ts index 29533a3df324..d475607b34f3 100644 --- a/packages/zone.js/test/common/Promise.spec.ts +++ b/packages/zone.js/test/common/Promise.spec.ts @@ -879,17 +879,6 @@ describe( }); describe('resolve/reject multiple times', () => { - it('should ignore second resolve', (done) => { - const nested = new Promise((res) => setTimeout(() => res('nested'))); - const p = new Promise((res) => { - res(nested); - res(1); - }); - p.then((v) => { - expect(v).toBe('nested'); - done(); - }); - }); it('should ignore second resolve', (done) => { const nested = new Promise((res) => setTimeout(() => res('nested'))); const p = new Promise((res) => { diff --git a/packages/zone.js/test/common/promise-disable-wrap-uncaught-promise-rejection.spec.ts b/packages/zone.js/test/common/promise-disable-wrap-uncaught-promise-rejection.spec.ts index 24ba5592d4c8..7388c89fb66a 100644 --- a/packages/zone.js/test/common/promise-disable-wrap-uncaught-promise-rejection.spec.ts +++ b/packages/zone.js/test/common/promise-disable-wrap-uncaught-promise-rejection.spec.ts @@ -110,4 +110,28 @@ describe('disable wrap uncaught promise rejection', () => { done(); }); }); + + it('should handle a custom object rejection with a rejection property without crashing the error logger', async () => { + await jasmine.spyOnGlobalErrorsAsync(() => { + const originalConsoleError = console.error; + console.error = jasmine.createSpy('consoleErr'); + + const rejectObj = { + rejection: 'custom-inner-rejection', + message: 'custom-error-message', + }; + + Zone.current.fork({name: 'promise-error-zone'}).run(() => { + Promise.reject(rejectObj); + }); + + return new Promise((res) => { + setTimeout(() => { + expect(console.error).toHaveBeenCalledWith(rejectObj); + console.error = originalConsoleError; + res(); + }); + }); + }); + }); }); diff --git a/packages/zone.js/test/common/zone.spec.ts b/packages/zone.js/test/common/zone.spec.ts index 2ee9894db051..41e516516189 100644 --- a/packages/zone.js/test/common/zone.spec.ts +++ b/packages/zone.js/test/common/zone.spec.ts @@ -5,11 +5,10 @@ * 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 {zoneSymbol} from '../../lib/common/utils'; -describe('Zone', function () { - const rootZone = Zone.current; +import {isNode} from '../../lib/common/utils'; +describe('Zone Common', function () { it('should have a name', function () { expect(Zone.current.name).toBeDefined(); }); @@ -436,6 +435,153 @@ describe('Zone', function () { log = []; }); + // https://github.com/angular/angular/issues/44446 + // https://github.com/angular/angular/issues/55590 + // https://github.com/angular/angular/issues/51328 + describe('__zone_symbol__enable_native_microtask_draining', () => { + it('should not drain the microtask queue too early without task (if the flag is enabled)', (done) => { + // Regression test for https://github.com/angular/angular/issues/44446. + // Verifies that a microtask scheduled inside an event task is not drained + // synchronously mid-stack when the native draining flag is enabled. + const globalAny = global as any; + globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true; + const zone = Zone.current; + const event = zone.scheduleEventTask( + 'test', + () => { + log.push('eventTask'); + zone.scheduleMicroTask('test', () => log.push('microTask')); + }, + undefined, + noop, + noop, + ); + log.push('after schedule eventTask'); + expect(log).toEqual(['after schedule eventTask']); + event.invoke(); + // At this point, we should not have invoked the microtask. + expect(log).toEqual(['after schedule eventTask', 'eventTask']); + globalAny[Zone.__symbol__('setTimeout')](() => { + expect(log).toEqual(['after schedule eventTask', 'eventTask', 'microTask']); + globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false; + done(); + }); + }); + + it('should not drain the microtask queue too early (if the flag is enabled)', (done) => { + // We need `document` in this test. + if (isNode) { + done(); + return; + } + + // Regression test for https://github.com/angular/angular/issues/44446. + // Verifies that a Promise.then() callback scheduled inside a DOM event listener + // is not drained synchronously before the main stack unwinds. + + const globalAny = global as any; + globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true; + const zone = Zone.current; + + zone.run(() => { + const listener = () => { + Promise.resolve().then(() => log.push('promise.then')); + }; + + document.body.addEventListener('click', listener); + document.body.click(); + log.push('main stack'); + + globalAny[Zone.__symbol__('setTimeout')](() => { + document.body.removeEventListener('click', listener); + expect(log).toEqual(['main stack', 'promise.then']); + globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false; + done(); + }); + }); + }); + + it('should surface unhandled promise rejections via unhandledrejection event (if the flag is enabled)', async () => { + // We need `window` in this test. + if (isNode) { + return; + } + + // Regression test for https://github.com/angular/angular/issues/55590. + // Verifies that unhandled promise rejections originating outside zone.js-patched + // code (e.g. a plain