Compare commits

...

116 Commits

Author SHA1 Message Date
leonsenft 4c0bc4345a release: cut the v21.2.22 release 2026-08-26 16:51:57 -07:00
Alan Agius 5aa6d97deb fix(platform-server): avoid stripping unicode whitespace during url resolution
Avoid trimming urlStr with String.prototype.trim() in resolveUrl to ensure URL parsing and resolution align with the WHATWG URL standard.
2026-08-25 10:00:02 -07:00
Alan Agius 73d8bbd27c fix(platform-server): update domino to latest version
Updates the domino dependency to the latest version as used in the main branch.
2026-08-24 10:41:12 -07:00
Jessica Janiuk 992d627b69 release: cut the v21.2.21 release 2026-08-19 12:52:45 -07:00
SkyZeroZx c19a36c2fb fix(platform-browser): disallow event handler attributes in Meta
Prevent arbitrary MetaDefinition properties from writing on* handlers directly to meta elements. Browser events can execute these handlers, including on meta elements rendered in the document body.

Reject handler attributes case-insensitively before matching or mutating tags.

Fixes #70126
2026-08-17 13:43:57 -07:00
Alon Mishne dca0a77488 release: cut the v21.2.20 release 2026-08-12 17:02:31 -07:00
Jaime Burgos caf616670f fix(http): run root interceptors in the terminal request chain
Represent withRequestsMadeViaParent() with an internal delegating backend so the interceptor handler can distinguish delegated clients from independent child configurations.
2026-07-31 15:13:30 -07:00
SkyZeroZx fec5977df4 fix(http): match header values exactly when deleting
Normalize value-specific HttpHeaders deletions before filtering. The string overload previously used String#indexOf and removed shorter values contained within the requested deletion value, potentially widening outgoing request metadata.

Preserve delete-all behavior only when no value is supplied, and cover string, array, and empty-string deletion.
2026-07-31 15:13:30 -07:00
SkyZeroZx e33d69a71c fix(http): preserve immutability of materialized clones
Prevent lazy HttpHeaders and HttpParams clones from reusing value arrays owned by a materialized source. Append and value-specific delete operations previously mutated those shared arrays, violating the immutable API contract and allowing request metadata to bleed into later requests.

Share value arrays until an update mutates a specific header or parameter, then copy only that array. Cover the affected append and delete paths with regression tests that materialize the source first.
2026-07-31 15:13:30 -07:00
Jaime Burgos 6afe6fa781 fix(core): sanitize host bindings on concrete hosts
Compute host binding security contexts against concrete hosts, including host directives, inheritance, dynamic directives, and createComponent hostElement usage.
2026-07-30 08:29:04 -07:00
Alan Agius 07cb840eac release: cut the v21.2.19 release 2026-07-29 22:44:48 +02:00
Alan Agius 7b884f585a fix(compiler): restrict possible event handler check to property names longer than 2 characters
Previously, the compiler disallowed translation of any attribute starting with 'on' for security reasons. This incorrectly disallowed translation of the 'on' attribute itself, which is not an event handler.

This commit introduces `isPossibleEventHandler` to verify that the property name has a length greater than 2 in addition to starting with 'on'. This allows attributes like 'on' to be translated while still correctly disallowing actual event handlers like 'onerror', 'onclick', etc.
2026-07-29 08:51:50 -07:00
Hexix23 948a8d6831 fix(http): distinguish repeated transfer cache params
Serialize transfer cache request parameters without comma-joining repeated values so distinct HttpClient requests cannot reuse the same cached response.
2026-07-29 08:51:50 -07:00
Hexix23 e2660c3dee fix(compiler): disallow i18n event attributes
Reject translated event-handler attributes so localization cannot bypass Angular event-attribute validation.
2026-07-29 08:51:50 -07:00
SkyZeroZx f34a93c946 fix(platform-server): update domino to latest version
Updates the domino dependency to the latest version as used in the main branch.

This update contains fixes for https://github.com/angular/domino/pull/32.
2026-07-29 08:43:40 -07:00
Jaime Burgos 9949dccce1 fix(http): enable xsrf for root-provided HttpClient
Include the XSRF interceptor in the root token factory so the automatically provided HttpClient retains the documented default protection without requiring provideHttpClient().
2026-07-24 13:24:13 -07:00
leonsenft 5d00aa2bd4 release: cut the v21.2.18 release 2026-07-08 15:02:15 -07:00
SkyZeroZx e86c31bf26 fix(service-worker): preserve referrer policy in asset requests
Preserve explicit referrer policy when the service worker reconstructs asset requests for cache-busted and redirected asset fetches.

For example, an application can load a script or image with referrerPolicy: 'same-origin' or 'origin' to limit referrer data. Dropping that policy can expose more of the current URL to that resource host.

(cherry picked from commit a7f52e5c30)
2026-07-08 11:24:32 -07:00
SkyZeroZx 1804f73bec fix(service-worker): preserve referrer in asset requests
Preserve referrer metadata when the service worker reconstructs asset requests for cache-busted and redirected asset fetches.

For example, an attacker with access to asset host logs could receive a reset token embedded in a page URL if the reconstructed request falls back to default referrer behavior instead of carrying referrer: ''.

(cherry picked from commit 99ad47e58f)
2026-07-08 11:24:32 -07:00
SkyZeroZx 91df739b80 fix(http): prevent caching of responses with Set-Cookie headers
Skip HttpTransferCache serialization for HTTP responses that contain a
Set-Cookie header.

Cookie-setting responses commonly represent session-specific,
user-specific, or security-sensitive state. Serializing their bodies into
SSR TransferState can embed sensitive data into the generated HTML, where
it may be reused during hydration or replayed by a shared cache/CDN.

(cherry picked from commit 80795defc6)
2026-07-07 10:15:02 -07:00
Alan Agius 8d22cc953b fix(compiler-cli): update babel dependencies to latest v7
Update babel dependencies to v7.29.7 to address CVE-2026-49356.

Fixes #69608
2026-07-06 14:01:46 -07:00
SkyZeroZx 5a693bafcd fix(core): reject dynamic script host elements
The previous fix for GHSA-692r-grfm-v8x7 was incomplete because it rejected script tags only when locating an explicit host element. Dynamic component instantiation can also infer the host element from the component selector.

Move the script-host rejection to the point where ComponentFactory has resolved the host element for either path, so createComponent rejects script hosts consistently.

(cherry picked from commit 135f3755b4)
2026-06-30 17:42:39 -07:00
SkyZeroZx 6bcce117fb fix(core): avoid caching missing locale data
Only cache locale data loaded from the global locale registry when an actual locale entry is found.

This prevents attacker-controlled missing locale identifiers from being retained indefinitely in SSR when locale lookup falls back to a parent locale or the built-in English locale, avoiding unbounded process memory growth in locale-aware pipes and formatters.

(cherry picked from commit ea8277ae37)
2026-06-24 13:04:05 -04:00
Andrew Scott 31c25e2e85 release: cut the v21.2.17 release 2026-06-10 12:54:23 -07:00
Jaime Burgos 88832c84f8 fix(core): validate lowercase SVG animation attribute names (#69269)
Normalize SVG animation attributeName lookup to also recognize lowercase attributename before allowing dynamic animation value bindings.

Add runtime and platform-server SSR regression coverage for lowercase attributename retargeting.

(cherry picked from commit d5e689af80)
2026-06-10 09:51:11 -07:00
Alan Agius 13fb0afe93 refactor(platform-server): deprecate ServerXhr (#69255)
XHR support in `@angular/platform-server` is deprecated because the underlying `xhr2` library does not safely handle redirects. Specifically, it can forward `Authorization` headers on cross-origin redirects (which leaks credentials) and is susceptible to denial-of-service (DoS) via redirect loops.

DEPRECATED: XHR support in `@angular/platform-server` is deprecated. Use standard `fetch` APIs instead.
2026-06-09 10:06:45 -07:00
SkyZeroZx 86a56dc279 fix(common): Limits date format string length
Introduces a maximum length of 256 characters for date format strings.

This prevents potential Denial of Service (DoS) attacks by throwing an
`INVALID_DATE_FORMAT` error if an excessively long format string is
provided to `formatDate` or `DatePipe`, safeguarding against performance
degradation or application crashes.

(cherry picked from commit 35de6b368c)
2026-06-08 13:19:55 -07:00
SkyZeroZx bcb1b7ea25 fix(http): preserve empty referrer option in HttpRequest
Preserve `referrer: ''` when constructing and cloning HttpRequest.

An empty string is a valid Fetch referrer value and is documented by
Angular as the way to omit referrer information for sensitive requests.
The previous truthy checks treated it as if the option was not provided,
causing requests to fall back to the browser default referrer behavior.

(cherry picked from commit 1e54b8f5ec)
2026-06-08 13:17:32 -07:00
SkyZeroZx b9d29381bb fix(service-worker): Strips sensitive headers on cross-origin redirects
Removes `Authorization`, `Cookie`, and `Proxy-Authorization` headers when a request is redirected to a different origin. This aligns with the Fetch API's redirect algorithm to prevent sensitive information from being sent to third-party origins.

(cherry picked from commit 423a109404)
2026-06-08 10:41:59 -07:00
Jaime Burgos a810a319d1 fix(http): Rejects non-HTTP(S) URLs in JSONP requests
Prevents JSONP requests from using URLs with unsupported protocols
for improved security.
2026-06-05 14:25:46 -07:00
Alan Agius 35510746b7 fix(platform-server): harden platform location origin validation during SSR
Add allowOriginChange option to ResolveUrlOptions in resolveUrl to enforce same-origin validation on resolved URLs. When set to false, it prevents any cross-origin changes (including HTTP/HTTPS URLs), aligning the emulated server-side platform location environment with browser security behavior.

Refactor ServerPlatformLocation.replaceState to use allowOriginChange: false instead of manual comparison, hardening state change validation against cross-origin URLs.

Add unit tests in url_spec.ts and platform_location_spec.ts for the origin validation changes.
2026-06-05 10:46:12 -07:00
Alan Agius a5f82b8315 refactor(platform-server): replace standard Error with RuntimeError
Update platform-server to use Angular 's native `RuntimeError` class.
This aligns error throwing patterns in platform-server with other packages of the framework such as core, common, and platform-browser.

For URL and host errors, the error messages are configured to return only the raw dynamic URL when `ngDevMode` is false (in production) to aid in troubleshooting without bloating production bundles.
2026-06-05 10:46:12 -07:00
Alan Agius bc55749698 fix(common): use cryptographically secure SHA-256 for transfer cache key generation
Replace the custom 64-bit non-cryptographic combined DJB2 hashing implementation in HttpTransferCache with a robust, pure JavaScript, synchronous SHA-256 algorithm.

Using DJB2 is vulnerable to pre-image and second-preimage attacks due to its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request inputs to poison the cache, potentially causing a CDN or the application to serve the wrong cached response to legitimate users.

SHA-256 provides strong cryptographic collision resistance, preventing cache key collision attacks. A custom synchronous implementation is required because the Web Crypto API (`crypto.subtle.digest`) is asynchronous, whereas the transfer cache state lookup and interceptor flow must operate synchronously.

Also, update the unit tests to dynamically verify the custom SHA-256 output against the native Web Crypto API.
2026-06-05 09:47:53 -07:00
SkyZeroZx d846326b07 fix(common): skip transfer cache for uncacheable HTTP traffic
Do not store HTTP transfer cache entries when either the request or response uses `Cache-Control: no-store`, `Cache-Control: private`, or `Cache-Control: no-cache`.

Also skip transfer cache when requests use the Fetch API `cache` option with `no-store` or `no-cache`.

Because transfer cache serializes SSR HTTP responses into the rendered HTML, Angular now treats these directives conservatively to avoid exposing sensitive or explicitly uncacheable data through `TransferState`.

(cherry picked from commit 4d150156ca)

(cherry picked from commit 64ce11fcd4)
2026-06-04 15:26:47 -07:00
SkyZeroZx e245d40c4d fix(http): skip transfer cache for fetch credentialed requests
Treat HttpClient requests using `credentials: 'include'` and `same-origin` as credentialed when deciding whether a response can be stored in the HTTP transfer cache.

The transfer cache already skips requests with `withCredentials`, `Cookie`, `Authorization`, or `Proxy-Authorization` because those responses may contain user-specific data. Fetch-backed requests can express the same credentialed behavior through the `credentials` option, so these responses must not be serialized into the SSR HTML.

This keeps credentialed SSR responses out of TransferState and aligns the cache eligibility check with the fetch request options supported by HttpClient.

(cherry picked from commit 8ec01970d2)
2026-06-04 15:26:47 -07:00
SkyZeroZx dc9c99636d fix(compiler): sanitize two-way properties
Apply schema-derived sanitizer resolution to TwoWayProperty ops so native two-way DOM bindings emit the same sanitizer as one-way property bindings.

Add compiler compliance coverage for innerHTML, srcdoc, URL, resource URL, and security-sensitive attribute cases.

(cherry picked from commit 9ca51ab0e4)
2026-06-03 13:06:27 -07:00
Matthieu Riegler 1523061137 fix(core): harden TransferState restoration against DOM clobbering
Reject non-script elements when reading the SSR transfer state payload by id.
This prevents attacker-controlled elements with a clobbered id from spoofing
hydration state.
2026-06-03 12:25:38 -07:00
Pawel Kozlowski 03188ddc9f release: cut the v21.2.16 release 2026-06-03 14:29:59 +02:00
Matthieu Riegler 736c4ab7e6 refactor(core): fix broken unit test.
This was broken by 3fd6897
2026-06-02 14:17:05 +02:00
Matthieu Riegler 3fd6897a67 fix(core): harden inherit definition feature against polluted prototypes
Stop inheritance traversal before built-in prototype objects and only read `ɵcmp`/`ɵdir` when they are own properties of a super type. This prevents polluted inherited properties from being treated as Angular defs during inheritance merging.

Also adds regression tests covering polluted `Object.prototype.ɵdir` and `Object.prototype.ɵcmp` to ensure polluted host metadata is not inherited.

(cherry picked from commit e695379354)
2026-06-02 13:26:00 +02:00
Michael Small db157e4aff docs: fix min/max form template examples
(cherry picked from commit 42391329c2)
2026-06-02 11:23:07 +02:00
Joey Perrott 70af5e8abd fix(docs-infra): secure update-assets script against RCE and SSRF
- Validate storedSha and storedBranch from _build-info.json.
- Validate latestSha returned from GitHub API.
- Validate branch in GithubClient.getShaForBranch and baseSha/headSha in GithubClient.getAffectedFiles.
- Use execFileSync instead of execSync to avoid shell execution.

TAG=agy
CONV=4e3e69ba-3f3d-416b-9ce4-9ef75486d2f3

(cherry picked from commit 3093edcad0)
2026-06-02 11:22:02 +02:00
Alan Agius 66821c4ed5 fix(platform-server): throw on suspicious URLs and restrict protocol-relative URLs
Currently, the platform-server attempts to neutralize URL hijacking and SSRF
bypasses by collapsing multiple leading slashes in relative paths. However,
sophisticated bypasses using obfuscated protocols (e.g., carriage returns or
newlines) or relative-like backslash paths can still lead to unexpected
origin takeovers.

This commit improves security by doing the following:
- Rejects protocol-relative URLs by throwing an error if they are not
  explicitly permitted via `allowProtocolRelative`.
- Strictly validates resolved URLs against the provided origin using
  `isSafeOriginChange`. If a URL unexpectedly shifts origins, an error
  is thrown.
- Permits origin changes only when standard absolute http/https protocols are
  explicitly declared in the input URL.

(cherry picked from commit 0b19c92d44)
2026-06-01 20:03:28 +02:00
Matthieu Riegler b74fb76d1a docs(docs-infra): simplify code block styles
#68940 introduced a regression that broke style for wrapped `code` blocks.
Simplifying the style by droping the unecessary gradient + before workaround fixes the issue.

(cherry picked from commit ec4f08bb94)
2026-06-01 18:36:58 +02:00
KirtiRamchandani 66d09558b6 docs: mention Angular Aria in accessibility guide
(cherry picked from commit 9b5952e3a3)
2026-06-01 18:33:36 +02:00
Kristiyan Kostadinov ae1c8a1f7a fix(compiler): move projection attributes into constants
We can save some memory by moving the `attrs` passed into the `projection` instruction into the constant pool.

(cherry picked from commit f0b28f6443)
2026-06-01 12:28:34 +02:00
Kam 9f6fda6815 docs: fix esbuild and TensorFlow branding on overview page
The esbuild card on the "What is Angular?" page rendered the bundler name three different ways (title "esbuild", link "ESBuild and Vite", body "Vite and ESBuild") so this unifies on the official lowercase "esbuild"; also corrects "Tensorflow" to "TensorFlow" to match the other brands on its line (Firebase, Material Design, Flutter, Google Cloud) which were already cased correctly.

(cherry picked from commit f777dd112e)
2026-06-01 12:19:08 +02:00
arturovt 1e5d76bfd9 docs: document //i18n(ph="name") placeholder syntax for template interpolations
The `//i18n(ph="name")` comment syntax for naming interpolation placeholders
in templates was not documented anywhere in the i18n guide, despite being a
supported compiler feature with test coverage.

Add a "Name the interpolation placeholder" subsection under "Mark text in
component template" in prepare.md, mirroring the existing subsection in
"Mark text in component code". Includes a cross-reference to the $localize
equivalent (`${var}:name:`) to help readers connect the two approaches.

Closes #52070

(cherry picked from commit 2b44a07ea7)
2026-06-01 12:18:09 +02:00
aparziale 22dd53ca97 docs: fix link debbuging and extended-diagnostics
Update link for 'Improve debbuging with better Angular error messages' and 'extended-diagnostics' sections

Fixed #69010

(cherry picked from commit cbc36f59e0)
2026-06-01 12:17:03 +02:00
Kam 1cd4f54aef fix(docs-infra): readable contrast for DEV/EXP api badges in light mode
The DEV (developer preview) and EXP (experimental) badges in the API reference list used `--page-background` for text, which is dark in dark mode (working as intended on the pale colored bg) but white in light mode, making the labels invisible against the near-white badge bg. Introduce an `--item-attr-text` CSS variable defaulting to `--page-background` and overridden to `--primary-contrast` in light mode, following the per-mode pattern the file already uses for `--item-attr-base-mix`.

(cherry picked from commit 0010ad5910)
2026-06-01 11:57:54 +02:00
Bhuvansh855 9d8ea2cc9a docs(forms): remove hasMetadata references from v21 guide 2026-06-01 11:56:00 +02:00
Kristiyan Kostadinov 69c0d48a0d fix(docs-infra): round up media queries
Uses 1px increments for media queries, rather than the 0.01px we have now which seem to be a bit too precise and cause the UI to be stuck between states in some cases.

I've also removed some unnecessary `calc`, because the calculation is happening inside Sass already.

Fixes #69020.

(cherry picked from commit 96ed0fe45b)
2026-06-01 11:34:44 +02:00
arturovt 7e38336dc7 fix(core): use Object.create(null) for LOCALE_DATA as a hardening measure
Prior to this commit, `LOCALE_DATA` was initialized as a plain object literal:

```typescript
let LOCALE_DATA: {[localeId: string]: any} = {};
```

While `__proto__` is neutralized by the `replace(/_/g, '-')` sanitization step (becoming `--proto--`), keys like `constructor` and `prototype` pass through unchanged and would modify special properties on `Object.prototype` if used as bracket notation keys on a plain object.

**Example attack through the public API:**

```typescript
// attacker calls the public registerLocaleData API with a crafted localeId
registerLocaleData(data, 'constructor');

// internally becomes:
LOCALE_DATA['constructor'] = data;
// → modifies Object.prototype.constructor for every object in the process

// or with extraData:
registerLocaleData(data, 'constructor', extraData);
// LOCALE_DATA['constructor'][LocaleDataIndex.ExtraData] = extraData;
// → Object.prototype[LocaleDataIndex.ExtraData] = extraData
// → every plain object in the process now has this property
// → affects JSON serialization, property enumeration, and framework internals

// consequence — any subsequent object created in the process is affected:
const user = getUserFromSession();
console.log(user[LocaleDataIndex.ExtraData]); // → attacker-controlled value
```

In a long-running SSR server this pollution persists for the lifetime of the process and affects all subsequent requests from all users.

**The fix** initializes `LOCALE_DATA` with `Object.create(null)`:

```typescript
let LOCALE_DATA: {[localeId: string]: any} = Object.create(null);
```

A null-prototype object has no prototype chain, so any key is treated as a plain string with no special behavior, making prototype pollution impossible regardless of input — without relying on the sanitization step as the sole protection.

(cherry picked from commit 0deac976f3)
2026-05-29 14:55:53 +02:00
arturovt 34c4e401ba fix(zone.js): validate __Zone_symbol_prefix to prevent DOM clobbering attacks
Previously, `__Zone_symbol_prefix` was read directly from `globalThis` without validating its type:

const symbolPrefix = global['__Zone_symbol_prefix'] || '__zone_symbol__';

This made it possible for DOM clobbering to interfere with Zone’s internal symbol handling. If an attacker injected a DOM element with the same name (for example via a form field or anchor ID), `global['__Zone_symbol_prefix']` could resolve to a DOM element instead of a string. Because DOM elements are truthy, the fallback would not be used, and Zone would construct invalid internal keys (e.g. “[object HTMLFormElement]...”), breaking patching and lookup logic in subtle ways.

This prevents DOM clobbering from influencing Zone’s internal symbol generation and keeps the patching system stable even in the presence of malicious or unexpected global values.

(cherry picked from commit e50f504b2f)
2026-05-29 14:54:19 +02:00
rootvector2 f6d8e642b0 fix(common): only strip a literal /index.html suffix from URLs
Hit this while exercising `Location.normalize` with route paths that end in non-`.html` suffixes.

The unescaped `.` in the strip regex inside `_stripIndexHtml` matches any character, so e.g. `/foo/indexXhtml` and `/foo/index_html` both collapse to `/foo` before the base-path strip and end up resolving to the wrong route.

Escape the dot so only the literal `/index.html` suffix is stripped.

(cherry picked from commit d109bf90d5)
2026-05-29 13:16:08 +02:00
Alan Agius 8206972189 refactor(platform-server): clean up and simplify url resolution utility
Trims leading/trailing whitespaces in resolveUrl to normalize input.

(cherry picked from commit e14d34e9ee)
2026-05-29 13:14:13 +02:00
Alan Agius d3170031b6 fix(platform-server): update domino to latest version
Updates the domino dependency to the latest version as used in the main branch.

This update contains fixes for https://github.com/angular/domino/pull/29.
2026-05-29 13:12:01 +02:00
arturovt 9b7d0e5034 docs: document i18n object forms for sourceLocale and locales in angular.json
The `sourceLocale` and `locales` entries in `angular.json` accept object
forms (with `code`, `baseHref`, and `subPath`) that were never documented.

- Add an `i18n options` reference section to workspace-config.md covering
  the full shape of `sourceLocale` and each `locales` entry, including the
  distinction between `baseHref` (HTML only) and `subPath` (HTML + output
  directory name)
- Add `i18n` to the project configuration options table in workspace-config.md
- Expand the suboptions table in merge.md to mention the object forms and
  link to the new reference section

Closes #59664

(cherry picked from commit 2f49d5dba4)
2026-05-29 11:53:54 +02:00
Alex Rickabaugh cea6588bb3 release: cut the v21.2.15 release 2026-05-28 09:51:14 -07:00
Kam 6b8202eab6 refactor(docs-infra): extract magic 27 in navigation-list tooltip threshold
The matTooltip on navigation list items was disabled when the label was
shorter than the literal `27`, repeated across four bindings in the
template. Lift the value to a protected readonly field so the threshold
has a name and lives in one place.

(cherry picked from commit 34d577f697)
2026-05-28 16:08:16 +02:00
Alan Agius eb1cbbf2eb fix(compiler): prevent namespaced SVG <style> elements from being stripped
Updates the template preparser to exclude namespaced SVG style tags (':svg:style') from the style elements set.

Previously, ':svg:style' elements were incorrectly classified as PreparsedElementType.STYLE, which caused them to be completely stripped from the final template DOM tree during the Render3 template transform and pushed into standard component stylesheets. By limiting the style element parsing to standard 'style' tags, namespaced SVG style tags remain safely in the template AST as normal DOM elements, preserving local SVG styling.

Closes #68977

(cherry picked from commit ec138c3645)
2026-05-28 14:02:52 +02:00
Bhuvansh855 8538bdce1c docs: fix grammar issues in resource guide
(cherry picked from commit 0e6cb4151c)
2026-05-28 13:48:06 +02:00
Yenya030 582a417bd2 fix(http): exclude withCredentials requests from transfer cache
Update the transfer cache check to safely exclude all requests sent with the `withCredentials` flag.

By default, the HTTP transfer cache avoids caching user-specific responses to prevent sensitive data exposure or incorrect caching. While requests with explicit headers like `Cookie` or `Authorization` are excluded by default, requests can also be sent with credentials via the `withCredentials` flag without having those headers explicitly declared on the request object.

To keep user-specific responses from being cached, exclude `withCredentials` requests unconditionally, even when the `includeRequestsWithAuthHeaders` option is set to true.

(cherry picked from commit 34090cb12e)
2026-05-27 14:13:21 -07:00
Yenya030 5c6d6df34b fix(http): skip TransferCache for cookie-bearing requests by default
Treat requests with a Cookie header like other auth-bearing requests and skip TransferCache caching them by default.

This preserves the explicit opt-in path via includeRequestsWithAuthHeaders, adds regression coverage for cookie-bearing requests, and updates the SSR guide to document the behavior.

(cherry picked from commit ab459798d9)
2026-05-27 14:13:21 -07:00
RonGamzu 29ceeffd40 docs: fix typos in source code comments
(cherry picked from commit 6f56202755)
2026-05-27 11:18:26 -07:00
Ricardo Chavarria 1a84668f0c docs(docs-infra): add Spanish community translation
Add https://docs.angular.lat/ (Español) to the community translations section.

(cherry picked from commit 48b4625fb3)
2026-05-27 11:16:59 -07:00
Kam 551a2a1f46 docs: fix preposition in libraries naming callout
The naming callout said the ng- prefix is "used from the Angular framework". Change to "used by", matching standard usage and the surrounding prose.

(cherry picked from commit 8c3e46fb53)
2026-05-27 11:09:46 -07:00
Harmeet Singh 84c6579a4e docs: clarify signals effect import source
(cherry picked from commit 741fcc4abf)
2026-05-27 11:08:54 -07:00
Kam 2b17b2db88 refactor(language-server): drop duplicate isAngularCore helpers in session
session.ts defined isAngularCore, isExternalAngularCore, and
isInternalAngularCore as byte-identical copies of the already-exported
versions in utils.ts. Only isAngularCore was used locally; the other
two were dead. handlers/template_info.ts already imports the utils
version. Remove the duplicates and import isAngularCore from utils.

(cherry picked from commit d808866f89)
2026-05-27 10:52:54 -07:00
Bhuvansh855 fd3573d99d fix(docs-infra): improve inline code layout
Remove inline-block layout behavior from inline code elements
to improve wrapping and spacing in multiline documentation
paragraphs.

(cherry picked from commit fdf0bf9a62)
2026-05-27 10:52:19 -07:00
Joey Perrott ef38852213 ci: configure setup and use pnpm in benchmark comparison workflow
The benchmark comparison workflow fails because it runs pnpm install
without setting up node and pnpm first. We configure the setup steps
manually so that checkouts from forks are supported.

Additionally, we update the benchmark comparison script (index.mts)
to use pnpm rather than hardcoded yarn commands to install
dependencies when checking out revisions.

(cherry picked from commit a648e8e914)
2026-05-27 10:51:43 -07:00
Alan Agius 2232a62bb2 fix(dev-infra): draft GitHub release to support immutable releases
Update the release tool to create the GitHub release in a draft state initially and publish it only after the extension asset (.vsix) has been successfully uploaded.

GitHub shifted towards immutable releases. If a release is published instantly upon creation,the assets will not be able to be uploaded.

(cherry picked from commit 26f4ed5056)
2026-05-27 10:50:26 -07:00
Andrew Scott ad5053b518 fix(zone.js): avoid type error on custom object rejection with rejection property
Ensure that when a custom object with a 'rejection' property is thrown as a raw promise rejection, the unhandled promise rejection error logger does not crash with a TypeError while trying to access undefined zone properties.

Also wrap microtask queue draining and task frame counter updates with defensive try-finally blocks to guarantee internal scheduler states are properly reset under any potential call stack exception unwinding scenarios.

(cherry picked from commit fa7580061b)
2026-05-27 10:45:19 -07:00
SkyZeroZx ca32fc1000 fix(service-worker): Preserves HTTP cache mode in asset group requests
Ensures explicit HTTP cache mode from incoming requests is forwarded and maintained when creating fetch requests for assets, aligning with expected fetch behavior and preventing unintended cache handling.

(cherry picked from commit 31399c2171)
2026-05-27 10:43:19 -07:00
SkyZeroZx b8bd49341d fix(service-worker): Preserves explicit 'credentials: omit' in asset requests
Ensures that explicitly provided `credentials: 'omit'` options are preserved
when creating new requests, preventing unintended credential inclusion.

(cherry picked from commit 5b0e9663e5)
2026-05-27 10:43:19 -07:00
leonsenft 251c8f2740 test(core): remove obsolete SVG script sanitization translation test (#68925)
Removes the `should throw error on translated SVG script ResourceURL
attributes` integration test from `security_integration_spec.ts`.

This test is now obsolete because SVG `<script>` elements are stripped during
template compilation (implemented in 90494cd909). As a result, they are no
longer present in the compiled template to trigger runtime sanitization,
causing this test (which expected a sanitization error to be thrown) to fail.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius dada86e43d fix(core): synchronize core sanitization schema with compiler (#68925)
Synchronizes the core's copy of the DOM security schema with the compiler-side schema definitions.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius 782e01594e fix(compiler): strip namespaced SVG script elements during template compilation (#68925)
Ensures that namespaced <script> elements (such as :svg:script) are correctly classified as PreparsedElementType.SCRIPT by the template preparser and stripped during compilation to prevent potential XSS vulnerabilities. Consequently, obsolete security schema mappings and runtime sanitization checks for <script> attributes have been removed since these elements are never present in compiled template outputs.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius ff12fe55ac fix(core): normalize tag names in runtime i18n attribute security context lookup (#68925)
Normalize namespaced tag names (e.g., :xhtml:a to a) inside i18nResolveSanitizer before looking up their security context. This ensures custom namespaced tag attributes undergo correct translation sanitization at runtime.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius 0b07f47bd6 fix(compiler): normalize tag names with custom namespaces in DomElementSchemaRegistry (#68925)
Custom XML/XHTML namespaced elements (e.g., <xhtml:a>) fall back to the standard HTML namespace during element creation at compile-time/runtime. However, their property and security context lookups inside the schema registry were incorrectly performed using the full namespaced tag name (e.g., :xhtml:a), which bypassed the default a|href sanitization registry and incorrectly returned SecurityContext.NONE instead of SecurityContext.URL.

This commit introduces tag name normalization inside DomElementSchemaRegistry for custom namespaces (other than the built-in svg and math namespaces). Custom namespaced tag names are now normalized to their simple HTML element counterparts for all registry queries, ensuring that correct property schema validation and dynamic security sanitization rules (such as URL sanitization) are enforced at runtime.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius cc1378d54b fix(compiler): sanitize dynamic href and xlink:href bindings on SVG a elements (#68925)
Dynamic bindings to `href` and `xlink:href` attributes on SVG `<a>` elements (`<svg:a>`) were previously unmapped in the DOM security schema. As a result, they bypassed sanitization completely, creating a potential XSS vulnerability if bound to untrusted user inputs (e.g., `javascript:` URLs).

This fix mitigates this risk by:

1. Registering `href` and `xlink:href` on `<svg:a>` elements under the `SecurityContext.URL` context in both the compiler and core DOM security schemas.

2. Enabling template compilation to output runtime URL sanitization checks (`ɵɵsanitizeUrl`) on these attributes.

3. Adding regression and verification test cases to ensure dynamic SVG link bindings are safely sanitized at runtime while static values are correctly allowed.

PR Close #68925
2026-05-27 10:42:29 -07:00
Alan Agius daaf32937f fix(core): support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation (#68925)
Updates `DomElementSchemaRegistry` to strip `:svg:` and `:math:` namespace prefixes
from tag names before querying `SECURITY_SCHEMA` at compile-time. This allows SVG
and MathML attributes to correctly match their security contexts during compilation.

PR Close #68925
2026-05-27 10:42:29 -07:00
Matthieu Riegler 37e8aadf87 fix(platform-server): prevent SSRF bypasses via backslash URLs in HttpClient
Encoding backslashes ensures that they are not normalized to slashes and where they could generate a protocol relative URL.

(cherry picked from commit 140c4d04cb)
2026-05-27 10:23:34 -07:00
Alan Agius 72696e244e fix(platform-server): secure location and document initialization against SSRF and path hijack
Normalizes the URL and path parsing logic inside platform-server by consolidating security checks and normalizations into a single, unified parseUrl helper function.

This includes:
- Collapsing multiple consecutive leading slashes and backslashes (e.g., // or /\) to a single forward slash to avoid protocol-relative parsing of path-like & relative inputs.
- Rejecting malformed absolute URLs that are otherwise accepted by lenient DOM parsers like Domino but rejected by standard WHATWG parsers, preventing SSRF / allowedHosts validation bypasses.
- Ensuring parseDocument gets the fully parsed and normalized URL instead of raw, unvalidated configuration values, preventing virtual document hostname adoption/origin hijack.
- Moving parseUrl unit tests into a dedicated url_spec.ts test file to keep platform_location_spec.ts clean and decoupled.

(cherry picked from commit 1307ff355c)
2026-05-27 10:22:42 -07:00
Matthieu Riegler 300f61feb3 fix(common): sanitize placeholder
The placeholder should be sanitized to prevent CSS/content injection.

(cherry picked from commit b56e865148)
2026-05-27 10:21:55 -07:00
Matthieu Riegler 7f4ac78994 fix(common): add upper bounds for digitsInfo
The prevents the `roundNumber` function from allocating a large array.

(cherry picked from commit dfdfbe34a5)
2026-05-27 10:21:02 -07:00
Matthieu Riegler e6fe77cc97 fix(core): sanitize meta selectors
Ensure that property/name are correctly escaped and doesn't break out of the intended selector.

(cherry picked from commit d5a489aed3)
2026-05-27 10:19:01 -07:00
Andrew Scott 525e1605a6 release: bump VSCode extension version to 21.2.4 2026-05-22 14:25:49 -07:00
Andrew Scott 4a41831326 fix(vscode-extension): disable language server in untrusted workspaces
Restrict untrusted workspace support to limited mode. Skip launching the language client and registering commands in restricted mode, and only start them once workspace trust has been explicitly granted.
2026-05-22 13:08:32 -07:00
Andrew Scott 6d8b156b45 fix(vscode-extension): restrict jsdoc markdown trust and harden document opening
Restrict JSDoc hover links to the custom openJSDocLink command and implement document
opening using safe workspace APIs.
2026-05-22 10:08:44 -07:00
Andrew Scott 82cf38ad95 fix(vscode-extension): prompt for confirmation before loading workspace tsdk
Harden the typescript.tsdk and js/ts.tsdk.path settings loading
in the VS Code extension client.

This change hardens tsdk loading by:
1. Ignoring workspace-level tsdk paths in untrusted workspaces.
2. Prompting the user for explicit confirmation before loading a
   workspace-level tsdk path in trusted workspaces, and saving the
   approval state in a secure, local workspaceState.
2026-05-22 10:05:07 -07:00
Andrew Scott 711f873e31 refactor(vscode-extension): Remove effectively dead code
Since bundled path is at the start of probe locations, it's always going to be found first.
Workspace versions will never be used. getProbeLocations is effectively dead and confusing code.

(cherry picked from commit d8c871ef80)
2026-05-22 09:58:27 -07:00
leonsenft 3fe8562b38 release: cut the v21.2.14 release 2026-05-20 15:34:51 -07:00
Kam 9627e02bde docs: link to ng new reference from installation guide
The installation guide walks a developer through `ng new <project-name>` but provides no link to the `ng new` CLI reference, leaving every option the command supports undiscoverable from the install flow. Link `ng new` in the prose to the reference page so options are one click away.

(cherry picked from commit 04f31cce3e)
2026-05-20 14:09:38 -07:00
arturovt a7b9ff5a58 docs: document FormBuilder.group() controlsConfig value shapes
The `@param` JSDoc for `FormBuilder.group()` previously described the argument only as “a collection of child controls”, without explaining the four supported value shapes:

* a raw value
* a `FormControlState`
* a `ControlConfig` tuple
* a pre-built `AbstractControl`

The fact that the second element of a `ControlConfig` tuple can accept `AbstractControlOptions` (for example to configure per-control `updateOn`) was especially non-obvious and undocumented.

This change adds a `@usageNotes` section with concrete examples covering each supported shape.

Closes #43984

(cherry picked from commit 3b8503f960)
2026-05-20 14:07:59 -07:00
Kam 1b6f780e2d docs: fix grammar slips on pipes guide
Fixes subject-verb agreement in the overview opener and date/currency example, a singular pronoun for a plural antecedent under change detection, and an "a object" -> "an object" a/an slip.

(cherry picked from commit 41a772ec0b)
2026-05-20 13:51:54 -07:00
arturovt d56f1a35ad docs: document barrel file caveat for @defer lazy chunks
Users often enable @defer expecting a separate lazy chunk but don't get
one, with no obvious error to explain why. The root cause is almost
always a barrel file import — the bundler treats the whole barrel as a
single module and can't split out individual exports.

Add a section to the defer guide that starts from the symptom (no lazy
chunk), shows the barrel import pattern that causes it, and gives the
direct-import fix.

Closes #52554

(cherry picked from commit d985957f09)
2026-05-20 13:47:47 -07:00
arturovt 232b21db55 docs: document content projection limitations
Add a Limitations section to the content projection guide covering two
common footguns that aren't obvious from the feature description alone.

First, projected content lives in the declaring component's view, not
the receiving component's. This means OnPush on the receiving component
doesn't prevent projected content from being checked on every parent
cycle, and projected content can't see the receiving component's
viewProviders.

Second, some library components (menus, tabs, lists) use ContentChildren
to wire up keyboard navigation and ARIA behavior and assume they own
their children directly. Projecting external content into them tends to
break that behavior silently.

Closes #49679

(cherry picked from commit de9e3d136e)
2026-05-20 13:31:50 -07:00
arturovt 1d6e71dd78 docs: clarify ngDoCheck invocation behavior with OnPush strategy
The previous documentation for `DoCheck` / `ngDoCheck` implied that the
default change-detector had run on the directive itself, which is
misleading. `ngDoCheck` is actually invoked when the *parent's*
change-detector checks the directive's input bindings — meaning it fires
even for `OnPush` components whose own change detection was skipped.

Updated three places in lifecycle_hooks.ts:
- Interface description: scopes "the check" to input bindings in the
  parent template and adds an explicit OnPush callout.
- "detects changes" clarified to "detects changes to the directive's
  input bindings".
- Method description: "after the default change-detector runs" →
  "after the default change-detector has checked the directive's input
  bindings in the parent template".

Fixes #48140

(cherry picked from commit ca44055166)
2026-05-20 11:12:11 -07:00
Kam 0c7f70e8ea fix(docs-infra): make absolute angular.dev hrefs relative in CLI option descriptions
CLI option descriptions are sourced from `@angular/cli` schema JSON
files, several of which contain absolute `https://angular.dev/...` URLs
in their `description` text. Those URLs render with the external-link
icon and push preview users out to production when viewed on
`next.angular.dev` or other dev previews. The path bypasses the existing
`link.mts` ban on absolute angular.dev links because option descriptions
go through `marked.parse` directly, without `AdevDocsRenderer`. Rewrite
the rendered hrefs whose values begin with `https://angular.dev/` (or
the `http:` variant) to root-relative paths so the resulting anchors
route through Angular's Router and resolve against the active
deployment. Subdomains such as `next.angular.dev/...` are intentionally
not rewritten because they refer to genuinely different deployments.

Closes #68795

(cherry picked from commit 745ee71c25)
2026-05-20 10:28:32 -07:00
Alan Agius 1ef4ea3e39 docs: update supported Angular versions table to reflect v19 end-of-life status
(cherry picked from commit b70515cada)
2026-05-20 10:10:01 -07:00
arturovt 395919ffeb docs: clarify viewProviders visibility with projected content
The providers vs. viewProviders section explained what happens but not
why — specifically, why projected content can still access a parent
component's viewProviders. Added an explanation that DI follows where
content was declared, not where it's rendered, so projecting a component
into a child's ng-content cuts off the child's viewProviders but leaves
the declaring component's viewProviders reachable.

Closes #49202

(cherry picked from commit 03161dc114)
2026-05-20 10:09:07 -07:00
tmpln 49113ac0ef fix(core): visit ICU expressions in signal migration schematics
Before this fix ICU expressions were not migrated.

(cherry picked from commit 048817dfa7)
2026-05-19 13:58:13 -07:00
aparziale 027c7c0c95 docs: update HTTP testing setup guidance
Update testing documentation clarify HttpClient testing providers

Fixed #68792

(cherry picked from commit 8ebc900067)
2026-05-19 13:41:17 -07:00
g.turri 7f444e1c7f docs: Fix accepted Provider type in doc snippet
out of the box my IDE tells me there an error on

    const testProviders: Provider[] = [provideHttpClient(), provideHttpClientTesting()];

because `provideHttpClient()` returns an `EnvironmentProviders` so
I can't put it in a variable of type `Provider[]`

(cherry picked from commit 4e55ceafc9)
2026-05-19 13:19:38 -07:00
june-by 34f89fb6c2 docs(compiler): add typeCheckHostBindings option to angular compiler options
(cherry picked from commit c49661b57b)
2026-05-19 13:09:30 -07:00
Alan Agius 68282dff9f fix(compiler): strip namespaced SVG script elements during template compilation
Ensures that namespaced <script> elements (such as :svg:script) are correctly classified as PreparsedElementType.SCRIPT by the template preparser and stripped during compilation to prevent potential XSS vulnerabilities. Consequently, obsolete security schema mappings and runtime sanitization checks for <script> attributes have been removed since these elements are never present in compiled template outputs.

(cherry picked from commit 90494cd909)
2026-05-19 13:06:05 -07:00
arturovt 099bf577ee fix(router): skip scroll-to-top on initial navigation when hydrating
When scrollPositionRestoration is enabled and the app hydrates an
SSR-rendered page, RouterScroller was unconditionally scrolling the
viewport to [0, 0] on the first imperative navigation. This discards
any scroll position the user established while the server-rendered
page was loading.

Fix by injecting IS_HYDRATION_DOM_REUSE_ENABLED into RouterScroller
and suppressing the scroll-to-top for the initial navigation only.
Subsequent navigations are unaffected.

Closes #64578

(cherry picked from commit 8ec0d1eee8)
2026-05-19 13:01:59 -07:00
Alan Agius fd05135da9 test(upgrade): exclude unit test files from E2E application sources
Exclude `**/*.spec.ts` files from the `srcs` glob of the `full_sources` target.

Previously, `module.spec.ts` was compiled as part of the application's main sources because the glob pattern only excluded `**/*_spec.ts` (E2E specs). Consequently, `module.spec.js` was generated and included in the runfiles of the E2E test target, causing the Protractor runner to load and execute it. This failed since the E2E testing runner does not have access to unit testing imports like `@angular/core/testing`.

(cherry picked from commit 7390af78b1)
2026-05-19 12:57:52 -07:00
tmpln c0f52272ed fix(core): do not insert todo when migrating void @Output
The following:

`@Output() someChange = new EventEmitter<void>();`

is correctly migrated to:

`readonly someChange = output<void>();`

However, a TODO is incorrectly inserted for subsequent emissions from
`someChange`, stating that an argument is expected.

(cherry picked from commit 16fe27bfef)
2026-05-18 13:25:05 -07:00
Matthieu Riegler d1736efc32 docs(docs-infra): Show function args
With this change non-overloaded functions also show the params + return type in a dedicated block.

(cherry picked from commit 872853fbcb)
2026-05-18 13:22:33 -07:00
Kam 73b0ada729 docs: open external anchors in adev markdown in a new tab
Several raw HTML `<a>` anchors in adev markdown link to external
sites without `target="_blank"`, so they open in the same tab
instead of a new one like the rest of the site's external links.
Add `target="_blank"` to match.

(cherry picked from commit b7255f9d13)
2026-05-18 13:18:25 -07:00
Alan Agius 0fb2724194 fix(core): reject script element as a dynamic component host
To enhance application security and prevent accidental or malicious script execution, this change ensures that dynamically mounting a component via createComponent directly onto a <script> element throws a runtime error in development mode. SVG <script> elements are also rejected. The error message is designed to be fully tree-shakable under production builds where ngDevMode is disabled.

(cherry picked from commit 0011664d1c)
2026-05-18 13:16:35 -07:00
Alan Agius 6652ec0115 refactor(core): align namespaced attribute validation and security schema contexts
Refactors the element security schema lookups and runtime attribute validation to
consistently account for SVG and MathML namespaces. This improves the modularity
and accuracy of security context mapping during template compilation and runtime
constant evaluation, eliminating redundant or false-positive lifecycle checks.

(cherry picked from commit cef4a095a2)
2026-05-18 13:09:44 -07:00
SkyZeroZx 938a7f3edd fix(core): makes resource URL sanitizer lookup case-insensitive
Ensures the resource map for URL sanitization is queried using lowercase tag and property names, improving robustness by handling case variations consistently.

(cherry picked from commit 00c284015c)
2026-05-18 13:07:39 -07:00
Andrew Scott fc434c1d0a refactor(compiler-cli): Remove unused properties of IndexedComponent interface
These properties aren't used in the Kythe indexer and can be removed

(cherry picked from commit 13911b156b)
2026-05-15 10:38:02 -07:00
201 changed files with 6121 additions and 1768 deletions
+12
View File
@@ -36,6 +36,18 @@ jobs:
# that the action was triggered by a team member.
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@ba726e7bca0b08b125ccc6f93c233749e1213c17
+196
View File
@@ -1,3 +1,199 @@
<a name="21.2.22"></a>
# 21.2.22 (2026-08-26)
### platform-server
| Commit | Type | Description |
| -- | -- | -- |
| [5aa6d97deb](https://github.com/angular/angular/commit/5aa6d97deb9ef1de14e23748b7fa74f97d183132) | fix | avoid stripping unicode whitespace during url resolution |
| [73d8bbd27c](https://github.com/angular/angular/commit/73d8bbd27cb46495426d4132975a1355b47ad915) | fix | update domino to latest version |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.21"></a>
# 21.2.21 (2026-08-19)
### platform-browser
| Commit | Type | Description |
| -- | -- | -- |
| [c19a36c2fb](https://github.com/angular/angular/commit/c19a36c2fbc51f41ce974ea1fd87f9f56b3e2444) | fix | disallow event handler attributes in Meta |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.20"></a>
# 21.2.20 (2026-08-12)
### core
| Commit | Type | Description |
| -- | -- | -- |
| [6afe6fa781](https://github.com/angular/angular/commit/6afe6fa781c2f0931f0aedd729b9884a8fe212ee) | fix | sanitize host bindings on concrete hosts |
### http
| Commit | Type | Description |
| -- | -- | -- |
| [fec5977df4](https://github.com/angular/angular/commit/fec5977df4dda3a10d5ce2923e3e06d86ba11ee7) | fix | match header values exactly when deleting |
| [e33d69a71c](https://github.com/angular/angular/commit/e33d69a71c5beb8fe5785b53fd6b37658334e8e0) | fix | preserve immutability of materialized clones |
| [caf616670f](https://github.com/angular/angular/commit/caf616670fd20d528aa69e0131cc17d60f0cc27d) | fix | run root interceptors in the terminal request chain |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.19"></a>
# 21.2.19 (2026-07-29)
### compiler
| Commit | Type | Description |
| -- | -- | -- |
| [e2660c3dee](https://github.com/angular/angular/commit/e2660c3deeccb86471f9c11b7e65ebcc89d84e9e) | fix | disallow i18n event attributes |
| [7b884f585a](https://github.com/angular/angular/commit/7b884f585abf50480c271d234c892c141eb61e2c) | fix | restrict possible event handler check to property names longer than 2 characters |
### http
| Commit | Type | Description |
| -- | -- | -- |
| [948a8d6831](https://github.com/angular/angular/commit/948a8d6831e8920b54663ec79421da95210e0e35) | fix | distinguish repeated transfer cache params |
| [9949dccce1](https://github.com/angular/angular/commit/9949dccce164638597496a6b3881043117dfd1df) | fix | enable xsrf for root-provided HttpClient |
### platform-server
| Commit | Type | Description |
| -- | -- | -- |
| [f34a93c946](https://github.com/angular/angular/commit/f34a93c946c017eff526cfb0c8d17f51e97f01e4) | fix | update domino to latest version |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.18"></a>
# 21.2.18 (2026-07-08)
### compiler-cli
| Commit | Type | Description |
| -- | -- | -- |
| [8d22cc953b](https://github.com/angular/angular/commit/8d22cc953bb9f970c6f86fb0f6e4d0665b874bfa) | fix | update babel dependencies to latest v7 |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [6bcce117fb](https://github.com/angular/angular/commit/6bcce117fbc616e4e721f351e9e038202e916522) | fix | avoid caching missing locale data |
| [5a693bafcd](https://github.com/angular/angular/commit/5a693bafcd49ef11ce687bd91a209e1a46652f42) | fix | reject dynamic script host elements |
### http
| Commit | Type | Description |
| -- | -- | -- |
| [91df739b80](https://github.com/angular/angular/commit/91df739b8022e2177bcc7214ed9a4e94098a4831) | fix | prevent caching of responses with Set-Cookie headers |
### service-worker
| Commit | Type | Description |
| -- | -- | -- |
| [1804f73bec](https://github.com/angular/angular/commit/1804f73becf425b5b1ccee9751d6e3fbd10e1f0b) | fix | preserve referrer in asset requests |
| [e86c31bf26](https://github.com/angular/angular/commit/e86c31bf26359aacce814167506dbdae585a7eb8) | fix | preserve referrer policy in asset requests |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.17"></a>
# 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 |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.16"></a>
# 21.2.16 (2026-06-03)
### common
| Commit | Type | Description |
| -- | -- | -- |
| [f6d8e642b0](https://github.com/angular/angular/commit/f6d8e642b0b215d2f9dbf1060abd24348c6cbf66) | fix | only strip a literal /index.html suffix from URLs |
### compiler
| Commit | Type | Description |
| -- | -- | -- |
| [ae1c8a1f7a](https://github.com/angular/angular/commit/ae1c8a1f7a7f1d4832da3b22e3763864fa5ff098) | fix | move projection attributes into constants |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [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 |
| -- | -- | -- |
| [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 |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.15"></a>
# 21.2.15 (2026-05-28)
### common
| Commit | Type | Description |
| -- | -- | -- |
| [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 <style> elements from being stripped |
| [cc1378d54b](https://github.com/angular/angular/commit/cc1378d54bd93f3882d732261be8e66720eb71b2) | fix | sanitize dynamic href and xlink:href bindings on SVG a elements ([#68925](https://github.com/angular/angular/pull/68925)) |
| [782e01594e](https://github.com/angular/angular/commit/782e01594e2ad9134c7385dcf3b518101b23ccab) | fix | strip namespaced SVG script elements during template compilation ([#68925](https://github.com/angular/angular/pull/68925)) |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [ff12fe55ac](https://github.com/angular/angular/commit/ff12fe55ace5e861ba261afb4c0480ff3c40a192) | fix | normalize tag names in runtime i18n attribute security context lookup ([#68925](https://github.com/angular/angular/pull/68925)) |
| [e6fe77cc97](https://github.com/angular/angular/commit/e6fe77cc97fd10351687416f938bf754aff4eb9f) | fix | sanitize meta selectors |
| [daaf32937f](https://github.com/angular/angular/commit/daaf32937fd5c46e411b26f7c082613716fe9550) | fix | support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation ([#68925](https://github.com/angular/angular/pull/68925)) |
| [dada86e43d](https://github.com/angular/angular/commit/dada86e43d847204f714d1a933084617ab941c0a) | fix | synchronize core sanitization schema with compiler ([#68925](https://github.com/angular/angular/pull/68925)) |
### http
| Commit | Type | Description |
| -- | -- | -- |
| [582a417bd2](https://github.com/angular/angular/commit/582a417bd27fdaf989e5065dbcdf1ad752faf70c) | fix | exclude withCredentials requests from transfer cache |
| [5c6d6df34b](https://github.com/angular/angular/commit/5c6d6df34bbeff3ce98f3b35875444f925cc8f51) | fix | skip TransferCache for cookie-bearing requests by default |
### platform-server
| Commit | Type | Description |
| -- | -- | -- |
| [37e8aadf87](https://github.com/angular/angular/commit/37e8aadf87b4facfcaf002a1557f8c393a362d97) | fix | prevent SSRF bypasses via backslash URLs in HttpClient |
| [72696e244e](https://github.com/angular/angular/commit/72696e244ed7646cca9ab9afc7769a2163943bda) | fix | secure location and document initialization against SSRF and path hijack |
### service-worker
| Commit | Type | Description |
| -- | -- | -- |
| [b8bd49341d](https://github.com/angular/angular/commit/b8bd49341ddcee10d119a9d4aa8e5736e4e5da53) | fix | Preserves explicit 'credentials: omit' in asset requests |
| [ca32fc1000](https://github.com/angular/angular/commit/ca32fc10001301e6174804f9abcfba62252334f4) | fix | Preserves HTTP cache mode in asset group requests |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.14"></a>
# 21.2.14 (2026-05-20)
### compiler
| Commit | Type | Description |
| -- | -- | -- |
| [68282dff9f](https://github.com/angular/angular/commit/68282dff9f9ef46540cca4bd38fc1ab739c8a783) | fix | strip namespaced SVG script elements during template compilation |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [c0f52272ed](https://github.com/angular/angular/commit/c0f52272ed337d4776bd4178cbbdc7f32037500f) | fix | do not insert todo when migrating void @Output |
| [938a7f3edd](https://github.com/angular/angular/commit/938a7f3eddda97a39edb9edcc8b4dd970858b3a2) | fix | makes resource URL sanitizer lookup case-insensitive |
| [0fb2724194](https://github.com/angular/angular/commit/0fb272419407a64a0a47096b03a911f4e7e83d79) | fix | reject script element as a dynamic component host |
| [49113ac0ef](https://github.com/angular/angular/commit/49113ac0eff852d987b5acb28a9bbda0242842cd) | fix | visit ICU expressions in signal migration schematics |
### router
| Commit | Type | Description |
| -- | -- | -- |
| [099bf577ee](https://github.com/angular/angular/commit/099bf577ee8f0bab60593a8fd2a1de7d298e3cd6) | fix | skip scroll-to-top on initial navigation when hydrating |
<!-- CHANGELOG SPLIT MARKER -->
<a name="21.2.13"></a>
# 21.2.13 (2026-05-13)
### core
@@ -10,6 +10,8 @@ import {get} from 'node:https';
import {posix} from 'node:path';
const GITHUB_API = 'https://api.github.com/repos/';
const SHA_REGEX = /^[0-9a-f]{40}$/i;
const BRANCH_REGEX = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
export class GithubClient {
#token;
@@ -30,6 +32,12 @@ export class GithubClient {
* @returns Promise<string[]>
*/
async getAffectedFiles(baseSha, headSha) {
if (!SHA_REGEX.test(baseSha)) {
throw new Error(`Invalid base SHA: ${baseSha}`);
}
if (!SHA_REGEX.test(headSha)) {
throw new Error(`Invalid head SHA: ${headSha}`);
}
const {files} = JSON.parse(await this.#httpGet(`${this.#api}/compare/${baseSha}...${headSha}`));
return files.map((f) => f.filename);
}
@@ -41,6 +49,9 @@ export class GithubClient {
* @returns Promise<string>
*/
async getShaForBranch(branch) {
if (!BRANCH_REGEX.test(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
const sha = await this.#httpGet(`${this.#api}/commits/${branch}`, {
headers: {Accept: 'application/vnd.github.VERSION.sha'},
});
@@ -49,7 +60,7 @@ export class GithubClient {
throw new Error(`Unable to extract the SHA for '${branch}'.`);
}
return sha;
return sha.trim();
}
#httpGet(url, options = {}) {
@@ -8,7 +8,7 @@
//tslint:disable:no-console
import assert from 'node:assert';
import {execSync} from 'node:child_process';
import {execFileSync} from 'node:child_process';
import {existsSync, constants as fsConstants} from 'node:fs';
import {
copyFile,
@@ -41,6 +41,16 @@ export async function updateAssets({repo, assetsPath, destPath}) {
await readFile(buildInfoPath, 'utf-8'),
);
const shaRegex = /^[0-9a-f]{40}$/i;
const branchRegex = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
if (!shaRegex.test(storedSha)) {
throw new Error(`Invalid SHA in build info: ${storedSha}`);
}
if (!branchRegex.test(storedBranch)) {
throw new Error(`Invalid branch name in build info: ${storedBranch}`);
}
assert(process.env.ANGULAR_READONLY_GITHUB_TOKEN);
const githubApi = new GithubClient(
repo,
@@ -63,6 +73,10 @@ export async function updateAssets({repo, assetsPath, destPath}) {
downstreamBranch = storedBranch;
}
if (!shaRegex.test(latestSha)) {
throw new Error(`Invalid SHA resolved: ${latestSha}`);
}
console.log(`Comparing ${storedSha}...${latestSha}.`);
const affectedFiles = await githubApi.getAffectedFiles(storedSha, latestSha);
const changedFiles = affectedFiles.filter((file) => file.startsWith(`${assetsPath}/`));
@@ -78,14 +92,18 @@ export async function updateAssets({repo, assetsPath, destPath}) {
try {
const execOptions = {cwd: temporaryDir, stdio: 'inherit'};
execSync('git init', execOptions);
execSync(`git remote add origin https://github.com/${repo}.git`, execOptions);
execFileSync('git', ['init'], execOptions);
execFileSync(
'git',
['remote', 'add', 'origin', `https://github.com/${repo}.git`],
execOptions,
);
// fetch a commit
execSync(`git fetch origin ${latestSha}`, execOptions);
execFileSync('git', ['fetch', 'origin', latestSha], execOptions);
// reset this repository's main branch to the commit of interest
execSync('git reset --hard FETCH_HEAD', execOptions);
execFileSync('git', ['reset', '--hard', 'FETCH_HEAD'], execOptions);
// get sha when files where changed
shaWhenFilesChanged = execSync(`git rev-list -1 ${latestSha} "${assetsPath}/"`, {
shaWhenFilesChanged = execFileSync('git', ['rev-list', '-1', latestSha, `${assetsPath}/`], {
encoding: 'utf8',
cwd: temporaryDir,
stdio: ['ignore', 'pipe', 'ignore'],
@@ -27,7 +27,7 @@
[href]="item.path"
target="_blank"
[matTooltip]="item.label"
[matTooltipDisabled]="itemLabel.length < 27"
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
matTooltipPosition="after"
[attr.aria-label]="item.label"
[matTooltipClass]="'API-tooltip'"
@@ -55,7 +55,7 @@
}"
(click)="emitClickOnLink(item)"
[matTooltip]="item.label"
[matTooltipDisabled]="itemLabel.length < 27"
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
matTooltipPosition="after"
[attr.aria-label]="item.label"
[matTooltipClass]="'API-tooltip'"
@@ -76,7 +76,7 @@
<div
class="docs-secondary-nav-header"
[matTooltip]="item.label"
[matTooltipDisabled]="itemLabel.length < 27"
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
matTooltipPosition="after"
[attr.aria-label]="item.label"
[matTooltipClass]="'API-tooltip'"
@@ -106,7 +106,7 @@
item.children && item.level === expandableLevel() && !item.isExpanded
"
[matTooltip]="item.label"
[matTooltipDisabled]="itemLabel.length < 27"
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
matTooltipPosition="after"
[attr.aria-label]="item.label"
[matTooltipClass]="'API-tooltip'"
@@ -39,6 +39,8 @@ export class NavigationList {
readonly linkClicked = output<void>();
protected readonly labelTruncationThreshold = 27;
private readonly navigationState = inject(NavigationState);
private readonly crossCategoryOrigin = this.navigationState.crossCategoryOrigin;
@@ -21,7 +21,6 @@ import {
ParameterEntry,
PipeEntry,
TypeAliasEntry,
EntryType,
} from '../entities.mjs';
import {CliCommand, CliOption} from '../cli-entities.mjs';
@@ -105,6 +104,7 @@ export type FunctionEntryRenderable = FunctionEntry &
export type FunctionSignatureMetadataRenderable = FunctionSignatureMetadata &
DocEntryRenderable & {
params: ParameterEntryRenderable[];
htmlReturnDescription?: string;
};
/** Documentation entity for a block augmented with transformed content for rendering. */
@@ -23,6 +23,7 @@ import {RawHtml} from './raw-html';
export function ClassMethodInfo(props: {
entry: FunctionSignatureMetadataRenderable;
hideUsageNotes?: boolean;
hideDescription?: boolean;
}) {
const entry = props.entry;
@@ -30,7 +31,9 @@ export function ClassMethodInfo(props: {
<div
className={`${REFERENCE_MEMBER_CARD_ITEM} ${entry.deprecated ? 'docs-reference-card-item-deprecated' : ''}`}
>
<RawHtml value={entry.htmlDescription} className={'docs-function-definition'} />
{!props.hideDescription && (
<RawHtml value={entry.htmlDescription} className={'docs-function-definition'} />
)}
{/* In case when method is overloaded we need to indicate which overload is deprecated */}
{entry.deprecated ? (
<div>
@@ -45,6 +48,9 @@ export function ClassMethodInfo(props: {
<div className={'docs-return-type'}>
<span className={PARAM_KEYWORD_CLASS_NAME}>@returns</span>
<CodeSymbol code={entry.returnType} />
{entry.htmlReturnDescription && (
<RawHtml value={entry.htmlReturnDescription} className="docs-parameter-description" />
)}
</div>
{entry.htmlUsageNotes && !props.hideUsageNotes ? (
<div className={'docs-usage-notes'}>
@@ -32,31 +32,43 @@ import {SectionUsageNotes} from './section-usage-notes';
export const signatureCard = (
name: string,
signature: FunctionSignatureMetadataRenderable,
opts: {id: string; printSignaturesAsHeader: boolean; hideUsageNotes?: boolean},
opts: {
id: string;
printSignaturesAsHeader: boolean;
hideUsageNotes?: boolean;
hideHeader?: boolean;
hideDescription?: boolean;
},
) => {
return (
<div id={opts.id} class={REFERENCE_MEMBER_CARD}>
<header class={REFERENCE_MEMBER_CARD_HEADER}>
{opts.printSignaturesAsHeader ? (
<HighlightTypeScript
code={printInitializerFunctionSignatureLine(
name,
signature,
// Always omit types in signature headers, to keep them short.
true,
)}
/>
) : (
<>
<h3>{name}</h3>
<div>
<CodeSymbol code={signature.returnType} />
</div>
</>
)}
</header>
{!opts.hideHeader && (
<header class={REFERENCE_MEMBER_CARD_HEADER}>
{opts.printSignaturesAsHeader ? (
<HighlightTypeScript
code={printInitializerFunctionSignatureLine(
name,
signature,
// Always omit types in signature headers, to keep them short.
true,
)}
/>
) : (
<>
<h3>{name}</h3>
<div>
<CodeSymbol code={signature.returnType} />
</div>
</>
)}
</header>
)}
<div class={REFERENCE_MEMBER_CARD_BODY}>
<ClassMethodInfo entry={signature} hideUsageNotes={opts.hideUsageNotes} />
<ClassMethodInfo
entry={signature}
hideUsageNotes={opts.hideUsageNotes}
hideDescription={opts.hideDescription}
/>
</div>
</div>
);
@@ -64,8 +76,8 @@ export const signatureCard = (
/** Component to render a function API reference document. */
export function FunctionReference(entry: FunctionEntryRenderable) {
// Use signatures as header if there are multiple signatures.
const printSignaturesAsHeader = entry.signatures.length > 1;
const hideSignatureCardDescription = !printSignaturesAsHeader;
return (
<div className={API_REFERENCE_CONTAINER}>
@@ -73,14 +85,15 @@ export function FunctionReference(entry: FunctionEntryRenderable) {
<DeprecationWarning entry={entry} />
<SectionApi entry={entry} />
<div className={REFERENCE_MEMBERS}>
{entry.signatures.length > 1 &&
entry.signatures.map((s, i) =>
signatureCard(s.name, getFunctionMetadataRenderable(s, entry.moduleName, entry.repo), {
id: `${s.name}_${i}`,
printSignaturesAsHeader,
hideUsageNotes: true,
}),
)}
{entry.signatures.map((s, i) =>
signatureCard(s.name, getFunctionMetadataRenderable(s, entry.moduleName, entry.repo), {
id: `${s.name}_${i}`,
printSignaturesAsHeader,
hideHeader: !printSignaturesAsHeader,
hideUsageNotes: hideSignatureCardDescription,
hideDescription: hideSignatureCardDescription,
}),
)}
</div>
<SectionDescription entry={entry} />
@@ -8,9 +8,9 @@
import {h} from 'preact';
import {ParameterEntryRenderable} from '../entities/renderables.mjs';
import {RawHtml} from './raw-html';
import {PARAM_GROUP_CLASS_NAME} from '../styling/css-classes.mjs';
import {CodeSymbol} from './code-symbols';
import {RawHtml} from './raw-html';
/** Component to render a function or method parameter reference doc fragment. */
export function Parameter(props: {param: ParameterEntryRenderable}) {
@@ -21,7 +21,9 @@ export function Parameter(props: {param: ParameterEntryRenderable}) {
{/*TODO: isOptional, isRestParam*/}
<span class="docs-param-keyword">@param</span>
<span class="docs-param-name">{param.name}</span>
<CodeSymbol code={param.type} />
<span class="docs-param-type">
<CodeSymbol code={param.type} />
</span>
<RawHtml value={param.htmlDescription} className="docs-parameter-description" />
</div>
);
@@ -43,4 +43,17 @@ describe('CLI docs to html', () => {
expect(cliTocs[0].textContent).toContain('ng component [name] [options]');
expect(cliTocs[1].textContent).toContain('ng c [name] [options]');
});
it('should rewrite absolute angular.dev hrefs in option descriptions to root-relative', async () => {
const renderableJson = await getRenderable(entryJson, '', 'angular/cli');
const localFragment = JSDOM.fragment(renderEntry(renderableJson));
const hrefs = Array.from(localFragment.querySelectorAll('a')).map((a) =>
a.getAttribute('href'),
);
// Absolute angular.dev URLs are rewritten to root-relative.
expect(hrefs).toContain('/cli-test-rewrite');
expect(hrefs).not.toContain('https://angular.dev/cli-test-rewrite');
// Subdomains are intentionally left as external.
expect(hrefs).toContain('https://next.angular.dev/cli-test-preview');
});
});
@@ -2,9 +2,7 @@
"name": "generate",
"command": "ng generate <schematic>",
"shortDescription": "Generates and/or modifies files based on a schematic.",
"aliases": [
"g"
],
"aliases": ["g"],
"deprecated": false,
"options": [
{
@@ -16,9 +14,7 @@
{
"name": "dry-run",
"type": "boolean",
"aliases": [
"d"
],
"aliases": ["d"],
"default": false,
"description": "Run through and reports activity without writing out results."
},
@@ -44,6 +40,12 @@
"type": "string",
"description": "The [collection:schematic] to run.",
"positional": 0
},
{
"name": "test-angular-dev-href-rewrite",
"type": "boolean",
"default": false,
"description": "See [angular.dev test](https://angular.dev/cli-test-rewrite) and [preview test](https://next.angular.dev/cli-test-preview)."
}
],
"subcommands": [
@@ -75,17 +77,13 @@
{
"name": "inline-style",
"type": "boolean",
"aliases": [
"s"
],
"aliases": ["s"],
"description": "Include styles inline in the root component.ts file. Only CSS styles can be included inline. Default is false, meaning that an external styles file is created and referenced in the root component.ts file."
},
{
"name": "inline-template",
"type": "boolean",
"aliases": [
"t"
],
"aliases": ["t"],
"description": "Include template inline in the root component.ts file. Default is false, meaning that an external template file is created and referenced in the root component.ts file. "
},
{
@@ -103,9 +101,7 @@
{
"name": "prefix",
"type": "string",
"aliases": [
"p"
],
"aliases": ["p"],
"default": "app",
"description": "A prefix to apply to generated selectors."
},
@@ -135,9 +131,7 @@
{
"name": "skip-tests",
"type": "boolean",
"aliases": [
"S"
],
"aliases": ["S"],
"default": false,
"description": "Do not create \"spec.ts\" test files for the application."
},
@@ -163,29 +157,17 @@
"name": "style",
"type": "string",
"default": "css",
"enum": [
"css",
"scss",
"sass",
"less"
],
"enum": ["css", "scss", "sass", "less"],
"description": "The file extension or preprocessor to use for style files."
},
{
"name": "view-encapsulation",
"type": "string",
"enum": [
"Emulated",
"None",
"ShadowDom",
"ExperimentalIsolatedShadowDom"
],
"enum": ["Emulated", "None", "ShadowDom", "ExperimentalIsolatedShadowDom"],
"description": "The view encapsulation strategy to use in the new application."
}
],
"aliases": [
"app"
],
"aliases": ["app"],
"deprecated": false
},
{
@@ -216,9 +198,7 @@
"description": "Adds a developer-defined type to the filename, in the format \"name.type.ts\"."
}
],
"aliases": [
"cl"
],
"aliases": ["cl"],
"deprecated": false
},
{
@@ -229,22 +209,15 @@
{
"name": "change-detection",
"type": "string",
"aliases": [
"c"
],
"aliases": ["c"],
"default": "Default",
"enum": [
"Default",
"OnPush"
],
"enum": ["Default", "OnPush"],
"description": "The change detection strategy to use in the new component."
},
{
"name": "display-block",
"type": "boolean",
"aliases": [
"b"
],
"aliases": ["b"],
"default": false,
"description": "Specifies if the style will contain `:host { display: block; }`."
},
@@ -269,27 +242,21 @@
{
"name": "inline-style",
"type": "boolean",
"aliases": [
"s"
],
"aliases": ["s"],
"default": false,
"description": "Include styles inline in the component.ts file. Only CSS styles can be included inline. By default, an external styles file is created and referenced in the component.ts file."
},
{
"name": "inline-template",
"type": "boolean",
"aliases": [
"t"
],
"aliases": ["t"],
"default": false,
"description": "Include template inline in the component.ts file. By default, an external template file is created and referenced in the component.ts file."
},
{
"name": "module",
"type": "string",
"aliases": [
"m"
],
"aliases": ["m"],
"description": "The declaring NgModule."
},
{
@@ -301,9 +268,7 @@
{
"name": "prefix",
"type": "string",
"aliases": [
"p"
],
"aliases": ["p"],
"description": "The prefix to apply to the generated component selector."
},
{
@@ -344,13 +309,7 @@
"name": "style",
"type": "string",
"default": "css",
"enum": [
"css",
"scss",
"sass",
"less",
"none"
],
"enum": ["css", "scss", "sass", "less", "none"],
"description": "The file extension or preprocessor to use for style files, or 'none' to skip generating the style file."
},
{
@@ -362,21 +321,12 @@
{
"name": "view-encapsulation",
"type": "string",
"aliases": [
"v"
],
"enum": [
"Emulated",
"None",
"ShadowDom",
"ExperimentalIsolatedShadowDom"
],
"aliases": ["v"],
"enum": ["Emulated", "None", "ShadowDom", "ExperimentalIsolatedShadowDom"],
"description": "The view encapsulation strategy to use in the new component."
}
],
"aliases": [
"c"
],
"aliases": ["c"],
"deprecated": false
},
{
@@ -392,10 +342,7 @@
{
"name": "type",
"type": "string",
"enum": [
"karma",
"browserslist"
],
"enum": ["karma", "browserslist"],
"description": "Specifies which type of configuration file to create.",
"positional": 0
}
@@ -423,9 +370,7 @@
{
"name": "module",
"type": "string",
"aliases": [
"m"
],
"aliases": ["m"],
"description": "The declaring NgModule."
},
{
@@ -437,9 +382,7 @@
{
"name": "prefix",
"type": "string",
"aliases": [
"p"
],
"aliases": ["p"],
"description": "A prefix to apply to generated selectors."
},
{
@@ -471,9 +414,7 @@
"description": "Whether the generated directive is standalone."
}
],
"aliases": [
"d"
],
"aliases": ["d"],
"deprecated": false
},
{
@@ -498,9 +439,7 @@
"description": "Adds a developer-defined type to the filename, in the format \"name.type.ts\"."
}
],
"aliases": [
"e"
],
"aliases": ["e"],
"deprecated": false
},
{
@@ -537,9 +476,7 @@
{
"name": "implements",
"type": "array",
"aliases": [
"guardType"
],
"aliases": ["guardType"],
"description": "Specifies which type of guard to create."
},
{
@@ -560,9 +497,7 @@
"description": "Do not create \"spec.ts\" test files for the new guard."
}
],
"aliases": [
"g"
],
"aliases": ["g"],
"deprecated": false
},
{
@@ -631,9 +566,7 @@
"positional": 1
}
],
"aliases": [
"i"
],
"aliases": ["i"],
"deprecated": false
},
{
@@ -656,9 +589,7 @@
{
"name": "prefix",
"type": "string",
"aliases": [
"p"
],
"aliases": ["p"],
"default": "lib",
"description": "A prefix to apply to generated selectors."
},
@@ -692,9 +623,7 @@
"description": "Creates a library based upon the standalone API, without NgModules."
}
],
"aliases": [
"lib"
],
"aliases": ["lib"],
"deprecated": false
},
{
@@ -711,9 +640,7 @@
{
"name": "module",
"type": "string",
"aliases": [
"m"
],
"aliases": ["m"],
"description": "The declaring NgModule."
},
{
@@ -742,16 +669,11 @@
"name": "routing-scope",
"type": "string",
"default": "Child",
"enum": [
"Child",
"Root"
],
"enum": ["Child", "Root"],
"description": "The scope for the new routing module."
}
],
"aliases": [
"m"
],
"aliases": ["m"],
"deprecated": false
},
{
@@ -774,9 +696,7 @@
{
"name": "module",
"type": "string",
"aliases": [
"m"
],
"aliases": ["m"],
"description": "The declaring NgModule."
},
{
@@ -809,9 +729,7 @@
"description": "Whether the generated pipe is standalone."
}
],
"aliases": [
"p"
],
"aliases": ["p"],
"deprecated": false
},
{
@@ -849,9 +767,7 @@
"description": "Do not create \"spec.ts\" test files for the new resolver."
}
],
"aliases": [
"r"
],
"aliases": ["r"],
"deprecated": false
},
{
@@ -883,9 +799,7 @@
"description": "Do not create \"spec.ts\" test files for the new service."
}
],
"aliases": [
"s"
],
"aliases": ["s"],
"deprecated": false
},
{
@@ -463,6 +463,7 @@
"isRestParam": false
}
],
"returnDescription": "A reference that can be used to unregister callbacks registered by this call.",
"rawComment": "/**\n * Register callbacks to be invoked the next time the application finishes rendering, during the\n * specified phases. The available phases are:\n * - `earlyRead`\n * Use this phase to **read** from the DOM before a subsequent `write` callback, for example to\n * perform custom layout that the browser doesn't natively support. Prefer the `read` phase if\n * reading can wait until after the write phase. **Never** write to the DOM in this phase.\n * - `write`\n * Use this phase to **write** to the DOM. **Never** read from the DOM in this phase.\n * - `mixedReadWrite`\n * Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if\n * it is possible to divide the work among the other phases instead.\n * - `read`\n * Use this phase to **read** from the DOM. **Never** write to the DOM in this phase.\n *\n * <div class=\"docs-alert docs-alert-critical\">\n *\n * You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite`\n * phases when possible, to avoid performance degradation.\n *\n * </div>\n *\n * Note that:\n * - Callbacks run in the following phase order *once, after the next render*:\n * 1. `earlyRead`\n * 2. `write`\n * 3. `mixedReadWrite`\n * 4. `read`\n * - Callbacks in the same phase run in the order they are registered.\n * - Callbacks run on browser platforms only, they will not run on the server.\n *\n * The first phase callback to run as part of this spec will receive no parameters. Each\n * subsequent phase callback in this spec will receive the return value of the previously run\n * phase callback as a parameter. This can be used to coordinate work across multiple phases.\n *\n * Angular is unable to verify or enforce that phases are used correctly, and instead\n * relies on each developer to follow the guidelines documented for each value and\n * carefully choose the appropriate one, refactoring their code if necessary. By doing\n * so, Angular is better able to minimize the performance degradation associated with\n * manual DOM access, ensuring the best experience for the end users of your application\n * or library.\n *\n * <div class=\"docs-alert docs-alert-important\">\n *\n * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs.\n * You must use caution when directly reading or writing the DOM and layout.\n *\n * </div>\n *\n * @param spec The callback functions to register\n * @param options Options to control the behavior of the callback\n *\n * @usageNotes\n *\n * Use `afterNextRender` to read or write the DOM once,\n * for example to initialize a non-Angular library.\n *\n * ### Example\n * ```angular-ts\n * @Component({\n * selector: 'my-chart-cmp',\n * template: `<div #chart>{{ ... }}</div>`,\n * })\n * export class MyChartCmp {\n * @ViewChild('chart') chartRef: ElementRef;\n * chart: MyChart|null;\n *\n * constructor() {\n * afterNextRender({\n * write: () => {\n * this.chart = new MyChart(this.chartRef.nativeElement);\n * }\n * });\n * }\n * }\n * ```\n *\n * @developerPreview\n */",
"returnType": "AfterRenderRef"
},
@@ -497,6 +498,7 @@
"isRestParam": false
}
],
"returnDescription": "A reference that can be used to unregister the callback registered by this call.",
"rawComment": "/**\n * Register a callback to be invoked the next time the application finishes rendering, during the\n * `mixedReadWrite` phase.\n *\n * <div class=\"docs-alert docs-alert-critical\">\n *\n * You should prefer specifying an explicit phase for the callback instead, or you risk significant\n * performance degradation.\n *\n * </div>\n *\n * Note that the callback will run\n * - in the order it was registered\n * - on browser platforms only\n * - during the `mixedReadWrite` phase\n *\n * <div class=\"docs-alert docs-alert-important\">\n *\n * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs.\n * You must use caution when directly reading or writing the DOM and layout.\n *\n * </div>\n *\n * @param callback A callback function to register\n * @param options Options to control the behavior of the callback\n *\n * @usageNotes\n *\n * Use `afterNextRender` to read or write the DOM once,\n * for example to initialize a non-Angular library.\n *\n * ### Example\n * ```angular-ts\n * @Component({\n * selector: 'my-chart-cmp',\n * template: `<div #chart>{{ ... }}</div>`,\n * })\n * export class MyChartCmp {\n * @ViewChild('chart') chartRef: ElementRef;\n * chart: MyChart|null;\n *\n * constructor() {\n * afterNextRender({\n * write: () => {\n * this.chart = new MyChart(this.chartRef.nativeElement);\n * }\n * });\n * }\n * }\n * ```\n *\n * @publicApi 20.0\n */",
"returnType": "AfterRenderRef"
}
@@ -53,11 +53,14 @@ export function getCliCardsRenderable(command: CliCommand): CliCardRenderable[]
return cards;
}
// Rewrite absolute angular.dev hrefs to root-relative so links stay in-site.
const angularDevHrefRegex = /(href=["'])https?:\/\/angular\.dev\//g;
function getRenderableOptions(items: CliOption[]): CliOptionRenderable[] {
return items.map((option) => ({
...option,
deprecated: option.deprecated ? {version: undefined} : undefined,
description: marked.parse(option.description) as string,
description: (marked.parse(option.description) as string).replace(angularDevHrefRegex, '$1/'),
}));
}
@@ -20,7 +20,7 @@ import {
setEntryFlags,
} from './jsdoc-transforms.mjs';
import {addModuleName} from './module-name.mjs';
import {addRenderableFunctionParams} from './params-transforms.mjs';
import {addHtmlReturnDescription, addRenderableFunctionParams} from './params-transforms.mjs';
import {addRepo} from './repo.mjs';
/** Given an unprocessed function entry, get the fully renderable function entry. */
@@ -50,11 +50,13 @@ export function getFunctionMetadataRenderable(
repo: string,
): FunctionSignatureMetadataRenderable {
return addHtmlAdditionalLinks(
addRenderableFunctionParams(
addHtmlUsageNotes(
setEntryFlags(
addHtmlJsDocTagComments(
addHtmlDescription(addRepo(addModuleName(entry, moduleName), repo)),
addHtmlReturnDescription(
addRenderableFunctionParams(
addHtmlUsageNotes(
setEntryFlags(
addHtmlJsDocTagComments(
addHtmlDescription(addRepo(addModuleName(entry, moduleName), repo)),
),
),
),
),
@@ -112,7 +112,7 @@ export function addHtmlUsageNotes<T extends HasJsDocTags>(entry: T): T & HasHtml
}
/** Given a markdown JsDoc text, gets the rendered HTML. */
function getHtmlForJsDocText(text: string): string {
export function getHtmlForJsDocText(text: string): string {
const mdToParse = convertLinks(wrapExampleHtmlElementsWithCode(text));
const parsed = parseMarkdown(mdToParse, {
apiEntries: getSymbolsAsApiEntries(),
@@ -7,7 +7,7 @@
*/
import {HasModuleName, HasParams, HasRenderableParams} from '../entities/traits.mjs';
import {addHtmlDescription} from './jsdoc-transforms.mjs';
import {addHtmlDescription, getHtmlForJsDocText} from './jsdoc-transforms.mjs';
import {addModuleName} from './module-name.mjs';
export function addRenderableFunctionParams<T extends HasParams & HasModuleName>(
@@ -22,3 +22,13 @@ export function addRenderableFunctionParams<T extends HasParams & HasModuleName>
params,
};
}
/** Converts `returnDescription` to `htmlReturnDescription` for rendering. */
export function addHtmlReturnDescription<
T extends {returnDescription?: string; moduleName: string},
>(entry: T): T & {htmlReturnDescription?: string} {
const htmlReturnDescription = entry.returnDescription
? getHtmlForJsDocText(entry.returnDescription)
: undefined;
return {...entry, htmlReturnDescription};
}
+8 -8
View File
@@ -24,43 +24,43 @@ $screen-xxl: 1800px;
}
@mixin for-tablet-portrait-up {
@media (min-width: calc($screen-xs + 0.01px)) {
@media (min-width: ($screen-xs + 1px)) {
@content;
}
}
@mixin for-tablet {
@media (min-width: calc($screen-xs + 0.01px)) and (max-width: $screen-md) {
@media (min-width: ($screen-xs + 1px)) and (max-width: $screen-md) {
@content;
}
}
@mixin for-tablet-up {
@media (min-width: calc($screen-sm + 0.01px)) {
@media (min-width: ($screen-sm + 1px)) {
@content;
}
}
@mixin for-tablet-landscape-up {
@media (min-width: calc($screen-md + 0.01px)) {
@media (min-width: ($screen-md + 1px)) {
@content;
}
}
@mixin for-desktop-up {
@media (min-width: calc($screen-lg + 0.01px)) {
@media (min-width: ($screen-lg + 1px)) {
@content;
}
}
@mixin for-large-desktop-up {
@media (min-width: calc($screen-xl + 0.01px)) {
@media (min-width: ($screen-xl + 1px)) {
@content;
}
}
@mixin for-extra-large-desktop-up {
@media (min-width: calc($screen-xxl + 0.01px)) {
@media (min-width: ($screen-xxl + 1px)) {
@content;
}
}
@@ -72,7 +72,7 @@ $screen-xxl: 1800px;
}
@mixin for-large-desktop-down {
@media (max-width: calc($screen-xl )) {
@media (max-width: $screen-xl) {
@content;
}
}
+6 -1
View File
@@ -340,6 +340,11 @@
}
}
.docs-param-type {
display: inline-block;
margin-inline-end: 0.5rem;
}
.docs-parameter-description {
p:first-child {
margin-block-start: 0;
@@ -357,7 +362,7 @@
padding-block: 1rem;
// & does not follow a function definition
&:not(.docs-function-definition + .docs-return-type) {
&:not(.docs-function-definition + .docs-return-type):not(:first-child) {
border-block-start: 1px solid var(--senary-contrast);
}
}
+3 -18
View File
@@ -80,26 +80,11 @@ $code-font-size: 0.875rem;
&:not(pre *) {
position: relative;
padding: 0 0.3rem;
// Fallback for older browsers
background: #e62600;
background: var(--red-to-orange-horizontal-gradient);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
background: var(--subtle-purple);
color: var(--hot-pink);
max-width: max-content;
border-radius: 0.25rem;
width: 100%;
display: inline-block;
&::before {
content: '';
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: var(--subtle-purple);
border-radius: 0.25rem;
z-index: -1;
}
a:not(.docs-anchor) > & {
position: relative;
@@ -99,6 +99,9 @@
<li>
<a href="https://angular.az/" title="Azərbaycanca">Azərbaycanca</a>
</li>
<li>
<a href="https://docs.angular.lat/" title="Español">Español</a>
</li>
<li>
<a href="https://angular-docs.tr/" title="Türkçe">Türkçe</a>
</li>
@@ -79,12 +79,15 @@
:host {
--item-attr-base-mix: 80%;
--item-attr-text: var(--page-background);
.docs-light-mode & {
--item-attr-base-mix: 20%;
--item-attr-text: var(--primary-contrast);
}
@media screen and (prefers-color-scheme: light) {
--item-attr-base-mix: 20%;
--item-attr-text: var(--primary-contrast);
}
}
@@ -110,7 +113,7 @@
var(--symbolic-yellow) var(--item-attr-base-mix),
var(--octonary-contrast)
);
color: var(--page-background);
color: var(--item-attr-text);
}
&.adev-experimental {
@@ -119,7 +122,7 @@
var(--symbolic-green) var(--item-attr-base-mix),
var(--octonary-contrast)
);
color: var(--page-background);
color: var(--item-attr-text);
}
&.adev-deprecated {
+2 -2
View File
@@ -18,11 +18,11 @@ Here is a set of instructions to help LLMs generate correct code that follows An
## Rules Files
Several editors, such as <a href="https://studio.firebase.google.com?utm_source=adev&utm_medium=website&utm_campaign=BUILD_WITH_AI_ANGULAR&utm_term=angular_devrel&utm_content=build_with_ai_angular_firebase_studio">Firebase Studio</a> have rules files useful for providing critical context to LLMs.
Several editors, such as <a href="https://studio.firebase.google.com?utm_source=adev&utm_medium=website&utm_campaign=BUILD_WITH_AI_ANGULAR&utm_term=angular_devrel&utm_content=build_with_ai_angular_firebase_studio" target="_blank">Firebase Studio</a> have rules files useful for providing critical context to LLMs.
| Environment/IDE | Rules File | Installation Instructions |
| :------------------- | :--------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Firebase Studio | <a download href="/assets/context/airules.md" target="_blank">airules.md</a> | <a href="https://firebase.google.com/docs/studio/set-up-gemini#custom-instructions">Configure `airules.md`</a> |
| Firebase Studio | <a download href="/assets/context/airules.md" target="_blank">airules.md</a> | <a href="https://firebase.google.com/docs/studio/set-up-gemini#custom-instructions" target="_blank">Configure `airules.md`</a> |
| Copilot powered IDEs | <a download="copilot-instructions.md" href="/assets/context/guidelines.md" target="_blank">copilot-instructions.md</a> | <a href="https://code.visualstudio.com/docs/copilot/copilot-customization#_custom-instructions" target="_blank">Configure `.github/copilot-instructions.md`</a> |
| Cursor | <a download href="/assets/context/angular-20.mdc" target="_blank">cursor.md</a> | <a href="https://docs.cursor.com/context/rules" target="_blank">Configure `cursorrules.md`</a> |
| JetBrains IDEs | <a download href="/assets/context/guidelines.md" target="_blank">guidelines.md</a> | <a href="https://www.jetbrains.com/help/junie/customize-guidelines.html" target="_blank">Configure `guidelines.md`</a> |
+3
View File
@@ -63,6 +63,9 @@ For example:
For full details of these and other tools, see the [Angular CDK accessibility overview](https://material.angular.dev/cdk/a11y/overview).
For custom-styled components that need reusable WAI-ARIA interaction patterns, [Angular Aria](guide/aria/overview) provides headless directives for patterns such as accordion, combobox, listbox, menu, tabs, and toolbar.
These directives handle keyboard interaction, ARIA attributes, focus management, and screen reader support while letting you provide the HTML structure and styling for your application.
### Augmenting native elements
Native HTML elements capture several standard interaction patterns that are important to accessibility.
@@ -249,3 +249,29 @@ placeholder, Angular compares against the `ngProjectAs` value instead of the ele
```
`ngProjectAs` supports only static values and cannot be bound to dynamic expressions.
## Caveats
### Projected content lives in the parent's view
Even though projected content is _rendered_ inside the receiving component, it is still owned by the component that declared it. Angular tracks it as part of the parent's view, which has a couple of side effects worth knowing about.
**Change detection:** Projected content is checked when the _parent_ runs change detection. If the receiving component uses `OnPush`, Angular can skip checking that component's own template — but it won't skip the projected content, because that belongs to the parent.
```angular-html
<!-- Parent template (default change detection) -->
<onpush-wrapper>
<!-- Still checked on every parent cycle, OnPush doesn't help here -->
<expensive-component />
</onpush-wrapper>
```
**Dependency injection:** Projected content gets its dependencies from the parent's injector, not from the receiving component's `viewProviders`. See [Providers and viewProviders](guide/di/hierarchical-dependency-injection) for details.
### Some library components don't support projected children
Certain components — menus, tabs, lists — use `ContentChildren` to find their children and wire up behavior like keyboard navigation, focus management, or ARIA attributes. They're written assuming they own their children directly, so projecting external content into them tends to break things in subtle ways.
For example, wrapping `<mat-menu-item>` elements in an extra layer and projecting them into `<mat-menu>` can silently break keyboard navigation and screen reader support. The query still finds the items, but the internal setup that makes them interactive may not work correctly when the items come from a different view context.
If a library component manages its children's behavior, check its docs before reaching for content projection — it may not be supported.
@@ -634,7 +634,7 @@ It doesn't need to continue searching the `ElementInjector` tree, nor does it ne
### `providers` vs. `viewProviders`
The `viewProviders` field is conceptually similar to `providers`, but there is one notable difference.
Configured providers in `viewProviders` are not visible to projected content that ends up as a logical children of the component.
Providers in `viewProviders` are only visible inside the component's own view — content projected into the component via `<ng-content>` cannot see them.
To see the difference between using `providers` and `viewProviders`, add another component to the example and call it `Inspector`.
`Inspector` will be a child of the `Child`.
@@ -705,6 +705,11 @@ These four bindings demonstrate the difference between `providers` and `viewProv
Remember that the dog emoji <code>🐶</code> is declared inside the `<#VIEW>` of `Child` and isn't visible to the projected content.
Instead, the projected content sees the whale <code>🐳</code>.
You might wonder why the projected `<app-inspector>` can still see <code>🐳</code> from `App`'s `viewProviders`.
The reason is that Angular DI tracks **where a component was declared**, not where it ends up being rendered.
`<app-inspector>` lives in `App`'s template — inside `App`'s `<#VIEW>` — so `App`'s `viewProviders` are fair game.
Projecting it into `Child` cuts off access to `Child`'s `viewProviders` (<code>🐶</code>), but `App`'s providers (<code>🐳</code>) are still reachable up the tree.
However, in the next output section though, the `Inspector` is an actual child component of `Child`, `Inspector` is inside the `<#VIEW>`, so when it asks for the `AnimalService`, it sees the dog <code>🐶</code>.
The `AnimalService` in the logical tree would look like this:
@@ -741,8 +746,10 @@ The `AnimalService` in the logical tree would look like this:
</app-root>
```
The projected content of `<app-inspector>` sees the whale <code>🐳</code>, not the dog <code>🐶</code>, because the dog <code>🐶</code> is inside the `<app-child>` `<#VIEW>`.
The `<app-inspector>` can only see the dog <code>🐶</code> if it is also within the `<#VIEW>`.
The projected `<app-inspector>` gets <code>🐳</code> because <code>🐶</code> belongs to `Child`'s view and projected content can't reach it.
<code>🐳</code> is accessible because `<app-inspector>` was declared in `App`'s template, so it can still walk up to `App`'s `viewProviders`.
The `<app-inspector>` that lives directly inside `Child`'s template (not projected) gets <code>🐶</code> — it's inside the `<#VIEW>`, so no boundary to cross.
### Visibility of provided tokens
@@ -2,7 +2,7 @@
Field metadata is reactive data you can attach to an individual field. Angular's built-in constraint validators like `required()` and `min()` use this system internally. In other words, every time you call a validator, you're contributing to a metadata key for that particular field.
This guide covers the metadata system in depth: how reducers combine contributions from multiple schema rules, how to write custom reducers, how reading composes with `hasMetadata()`, and how managed metadata ties lifecycle-aware objects to individual fields.
This guide covers the metadata system in depth: how reducers combine contributions from multiple schema rules, how to write custom reducers, how to read metadata values from fields, and how managed metadata ties lifecycle-aware objects to individual fields.
## You have already been using metadata
@@ -126,23 +126,15 @@ The logic function receives the field's context, which exposes `value` as a sign
## Reading metadata from a field
`hasMetadata(key)` returns `true` if any schema rule registered the key on this field. `state.metadata(key)` returns `undefined` when no rule has registered the key, and a signal of the current reduced value otherwise.
`state.metadata(key)` returns `undefined` when no rule has registered the key, and a signal of the current reduced value otherwise.
```ts
registrationForm.username().hasMetadata(USERNAME_HELP); // true if any metadata() rule registered this key
const usernameHelp = registrationForm.username().metadata(USERNAME_HELP);
```
The shape of that inner value (whether it can itself be `undefined`, what type it holds) depends on the key's reducer. Reducers are covered in the next section.
When the key may not be registered, gate the read with `hasMetadata()`:
```angular-html
@if (registrationForm.username().hasMetadata(USERNAME_HELP)) {
<p class="help">{{ registrationForm.username().metadata(USERNAME_HELP)!() }}</p>
}
```
When you know a rule always registers the key (because the schema in the same file does so), you can skip the `hasMetadata()` check and use optional chaining as a compact alternative:
When the key may not be registered, use optional chaining:
```ts
const message = registrationForm.username().metadata(USERNAME_HELP)?.();
@@ -478,7 +478,7 @@ import {form, FormField, required, min, max} from '@angular/forms/signals';
imports: [FormField],
template: `
<label>
Age (between {{ ageForm.age().min() }} and {{ ageForm.age().max() }})
Age (between {{ ageForm.age().min?.() }} and {{ ageForm.age().max?.() }})
<input type="number" [formField]="ageForm.age" />
</label>
@@ -519,12 +519,8 @@ import {form, FormField, max} from '@angular/forms/signals';
</label>
<label>
Quantity (max: {{ inventoryForm.quantity().max() }})
<input
type="number"
[formField]="inventoryForm.quantity"
[max]="inventoryForm.quantity().max()"
/>
Quantity (max: {{ inventoryForm.quantity().max?.() }})
<input type="number" [formField]="inventoryForm.quantity" />
</label>
`,
})
+13 -5
View File
@@ -8,15 +8,12 @@ At the end, tests can verify that the app made no unexpected requests.
## Setup for testing
To begin testing usage of `HttpClient`, configure `TestBed` and include `provideHttpClient()` and `provideHttpClientTesting()` in your test's setup. This configures `HttpClient` to use a test backend instead of the real network. It also provides `HttpTestingController`, which you'll use to interact with the test backend, set expectations about which requests have been made, and flush responses to those requests. `HttpTestingController` can be injected from `TestBed` once configured.
IMPORTANT: Keep in mind to provide `provideHttpClient()` **before** `provideHttpClientTesting()`, as `provideHttpClientTesting()` will overwrite parts of `provideHttpClient()`. Doing it the other way around can potentially break your tests.
To begin testing usage of `HttpClient`, configure `TestBed` and include `provideHttpClientTesting()` in your test's setup. `HttpClient` is provided by Angular's test environment, and `provideHttpClientTesting()` configures it to use a test backend instead of the real network. It also provides `HttpTestingController`, which you'll use to interact with the test backend, set expectations about which requests have been made, and flush responses to those requests. `HttpTestingController` can be injected from `TestBed` once configured.
```ts
TestBed.configureTestingModule({
providers: [
// ... other test providers
provideHttpClient(),
provideHttpClientTesting(),
],
});
@@ -26,13 +23,24 @@ const httpTesting = TestBed.inject(HttpTestingController);
Now when your tests make requests, they will hit the testing backend instead of the normal one. You can use `httpTesting` to make assertions about those requests.
### Configuring `HttpClient` in tests
If a test needs to configure `HttpClient` features, such as interceptors, include `provideHttpClient(...)` before `provideHttpClientTesting()`.
IMPORTANT: Keep in mind to provide `provideHttpClient()` **before** `provideHttpClientTesting()`, as `provideHttpClientTesting()` will overwrite parts of `provideHttpClient()`. Doing it the other way around can potentially break your tests.
```ts
TestBed.configureTestingModule({
providers: [provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
});
```
## Expecting and answering requests
For example, you can write a test that expects a GET request to occur and provides a mock response:
```ts
TestBed.configureTestingModule({
providers: [ConfigService, provideHttpClient(), provideHttpClientTesting()],
providers: [ConfigService, provideHttpClientTesting()],
});
const httpTesting = TestBed.inject(HttpTestingController);
+7 -4
View File
@@ -31,10 +31,12 @@ Use the `i18n` project option in the [`angular.json`][GuideWorkspaceConfig] work
The following sub-options identify the source language and tell the compiler where to find supported translations for the project.
| Suboption | Details |
| :------------- | :--------------------------------------------------------------------------- |
| `sourceLocale` | The locale you use within the application source code \(`en-US` by default\) |
| `locales` | A map of locale identifiers to translation files |
| Suboption | Details |
| :------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sourceLocale` | The locale you use within the application source code \(`en-US` by default\). Can also be an object with `code`, `baseHref`, and `subPath` properties. |
| `locales` | A map of locale identifiers to translation files. Each entry can also be an object with `translation`, `baseHref`, and `subPath` properties. |
For the full list of `i18n` properties and their types, see [i18n options][GuideWorkspaceConfigI18n].
### `angular.json` for `en-US` and `fr` example
@@ -149,3 +151,4 @@ TLDR: Compile once, then translate for each locale.
[GuideI18nCommonMergeGenerateApplicationVariantsForEachLocale]: guide/i18n/merge#generate-application-variants-for-each-locale 'Generate application variants for each locale - Merge translations into the application | Angular'
[GuideI18nCommonTranslationFilesChangeTheSourceLanguageFileFormat]: guide/i18n/translation-files#change-the-source-language-file-format 'Change the source language file format - Work with translation files | Angular'
[GuideWorkspaceConfig]: reference/configs/workspace-config 'Angular workspace configuration | Angular'
[GuideWorkspaceConfigI18n]: reference/configs/workspace-config#i18n-options 'i18n options - Angular workspace configuration | Angular'
+20
View File
@@ -48,6 +48,26 @@ The following example shows the `<ng-container>` element transformed into a non-
<docs-code path="adev/src/content/examples/i18n/src/app/app.component.html" region="i18n-ng-container"/>
### Name the interpolation placeholder
By default, Angular generates a placeholder name for each interpolation in a translated message. To give it a meaningful name that helps translators understand the context, add an `//i18n(ph="name")` comment inside the interpolation.
```html
<element i18n>{{ expression //i18n(ph="placeholder_name") }}</element>
```
For example:
```html
<p i18n>Hello, {{ username //i18n(ph="name") }}!</p>
```
This is the template equivalent of naming a placeholder in component code with [`$localize`][ApiLocalizeInitLocalize]:
```ts
$localize`Hello, ${username}:name:!`;
```
## Mark element attributes for translations
In a component template, the i18n metadata is the value of the `i18n-{attribute_name}` attribute.
+2
View File
@@ -3,6 +3,8 @@
Signals are useful because they notify interested consumers when they change. An **effect** is an operation that runs whenever one or more signal values change. You can create an effect with the `effect` function:
```ts
import {effect} from '@angular/core';
effect(() => {
console.log(`The current count is: ${count()}`);
});
+3 -3
View File
@@ -28,7 +28,7 @@ const firstName = computed(() => {
if (userResource.hasValue()) {
// `hasValue` serves 2 purposes:
// - It acts as type guard to strip `undefined` from the type
// - If protects against reading a throwing `value` when the resource is in error state
// - It protects against reading a throwing `value` when the resource is in error state
return userResource.value().firstName;
}
@@ -116,11 +116,11 @@ The `status` signal provides a specific `ResourceStatus` that describes the stat
| `'idle'` | `undefined` | The resource has no valid request and the loader has not run. |
| `'error'` | `undefined` | The loader has encountered an error. |
| `'loading'` | `undefined` | The loader is running as a result of the `params` value changing. |
| `'reloading'` | Previous value | The loader is running as a result calling of the resource's `reload` method. |
| `'reloading'` | Previous value | The loader is running as a result of calling the resource's `reload` method. |
| `'resolved'` | Resolved value | The loader has completed. |
| `'local'` | Locally set value | The resource's value has been set locally via `.set()` or `.update()` |
You can use this status information to conditionally display user interface elements, such loading indicators and error messages.
You can use this status information to conditionally display user interface elements, such as loading indicators and error messages.
## Reactive data fetching with `httpResource`
+7 -3
View File
@@ -432,7 +432,7 @@ To configure this, update your `angular.json` file as follows:
You can customize how Angular caches HTTP responses during server‑side rendering (SSR) and reuses them during hydration by configuring `HttpTransferCacheOptions`.
This configuration is provided globally using `withHttpTransferCacheOptions` inside `provideClientHydration()`.
By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization` or `Proxy-Authorization` headers. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration.
By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials` or Fetch API `credentials` modes that can send credentials. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), or when the Fetch API `cache` option is set to `no-store` or `no-cache`. Responses that carry a `Set-Cookie` header are also skipped. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration.
```ts
import {bootstrapApplication} from '@angular/platform-browser';
@@ -467,6 +467,8 @@ withHttpTransferCacheOptions({
IMPORTANT: Avoid including sensitive headers like authentication tokens. These can leak user‑specific data between requests.
Including `Cache-Control` in `includeHeaders` only makes that header available on the hydrated response. Angular already evaluates `Cache-Control` headers automatically when deciding whether a request or response is eligible for transfer cache.
---
### `includePostRequests`
@@ -486,8 +488,8 @@ Use this only when `POST` requests are **idempotent** and safe to reuse between
### `includeRequestsWithAuthHeaders`
Determines whether requests containing `Authorization` or `Proxy‑Authorization` headers are eligible for caching.
By default, these are excluded to prevent caching user‑specific responses.
Determines whether requests containing `Authorization`, `Proxy‑Authorization`, or `Cookie` headers are eligible for caching.
By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` or Fetch API `credentials` set to `include` or `same-origin` are also excluded by default.
```ts
withHttpTransferCacheOptions({
@@ -558,6 +560,8 @@ To disable caching for an individual request, you can specify the [`transferCach
httpClient.get('/api/sensitive-data', {transferCache: false});
```
`HttpTransferCache` does not cache requests or responses that explicitly opt out of caching. Angular skips transfer cache entries when a request includes a `Cache-Control` header with `no-store`, `no-cache`, or `private`, or when the request uses the Fetch API `cache` option set to `no-store` or `no-cache`. Responses with `Cache-Control: no-store`, `Cache-Control: no-cache`, or `Cache-Control: private` are also not stored in the transfer cache. Responses that include a `Set-Cookie` header are likewise not stored, as they typically carry user-specific state.
NOTE: If your application uses different HTTP origins to make API calls on the server and on the client, the `HTTP_TRANSFER_CACHE_ORIGIN_MAP` token allows you to establish a mapping between those origins, so that `HttpTransferCache` feature can recognize those requests as the same ones and reuse the data cached on the server during hydration on the client.
## Configuring a server
+31
View File
@@ -349,6 +349,37 @@ By default, when rendering an application on the server (either using SSR or SSG
To render the main content of `@defer` blocks on the server (both SSR and SSG), you can enable [the Incremental Hydration feature](/guide/incremental-hydration) and configure `hydrate` triggers for the necessary blocks.
## Barrel files and lazy chunks
If you're using `@defer` but not seeing a separate lazy chunk in your build output, check how you're importing the deferred component. Importing through a barrel file (`index.ts`) is a common culprit — bundlers see the barrel as a single module and keep all its exports together, so your component ends up in the main bundle regardless of `@defer`.
```typescript
// index.ts
export {HeavyComponent} from './heavy.component';
export {OtherComponent} from './other.component';
```
```typescript
// parent.component.ts
import {HeavyComponent} from './index'; // pulls in OtherComponent too
@Component({
imports: [HeavyComponent],
template: `@defer {
<heavy-component />
}`,
})
export class ParentComponent {}
```
The fix is straightforward — import directly from the component's own file:
```typescript
import {HeavyComponent} from './heavy.component';
```
That's enough for the bundler to split it into its own chunk and load it lazily when the trigger fires.
## Best practices for deferring views
### Avoid cascading loads with nested `@defer` blocks
+3 -3
View File
@@ -2,7 +2,7 @@
## Overview
Pipes are a special operator in Angular template expressions that allows you to transform data declaratively in your template. Pipes let you declare a transformation function once and then use that transformation across multiple templates. Angular pipes use the vertical bar character (`|`), inspired by the [Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>).
Pipes are special operators in Angular template expressions that allow you to transform data declaratively in your template. Pipes let you declare a transformation function once and then use that transformation across multiple templates. Angular pipes use the vertical bar character (`|`), inspired by the [Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>).
NOTE: Angular's pipe syntax deviates from standard JavaScript, which uses the vertical bar character for the [bitwise OR operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_OR). Angular template expressions do not support bitwise operators.
@@ -33,7 +33,7 @@ export class ShoppingCart {
}
```
When Angular renders the component, it will ensure that the appropriate date format and currency is based on the locale of the user. If the user is in the United States, it would render:
When Angular renders the component, it will ensure that the appropriate date format and currency are based on the locale of the user. If the user is in the United States, it would render:
```angular-html
<main>
@@ -162,7 +162,7 @@ Always use parentheses in your expressions when operator precedence may be ambig
### Change detection with pipes
By default, all pipes are considered `pure`, which means that it only executes when a primitive input value (such as a `String`, `Number`, `Boolean`, or `Symbol`) or a object reference (such as `Array`, `Object`, `Function`, or `Date`) is changed. Pure pipes offer a performance advantage because Angular can avoid calling the transformation function if the passed value has not changed.
By default, all pipes are considered `pure`, which means that they only execute when a primitive input value (such as a `String`, `Number`, `Boolean`, or `Symbol`) or an object reference (such as `Array`, `Object`, `Function`, or `Date`) is changed. Pure pipes offer a performance advantage because Angular can avoid calling the transformation function if the passed value has not changed.
As a result, this means that mutations to object properties or array items are not detected unless the entire object or array reference is replaced with a different instance. If you want this level of change detection, refer to [detecting changes within arrays or objects](#detecting-change-within-arrays-or-objects).
+2 -3
View File
@@ -53,11 +53,10 @@ The `setupFiles` and `providersFile` options are particularly useful for managin
For example, you could create a `src/test-providers.ts` file to provide `provideHttpClientTesting` to all your tests:
```typescript {header: "src/test-providers.ts"}
import {Provider} from '@angular/core';
import {provideHttpClient} from '@angular/common/http';
import {EnvironmentProviders, Provider} from '@angular/core';
import {provideHttpClientTesting} from '@angular/common/http/testing';
const testProviders: Provider[] = [provideHttpClient(), provideHttpClientTesting()];
const testProviders: (Provider | EnvironmentProviders)[] = [provideHttpClientTesting()];
export default testProviders;
```
@@ -62,7 +62,7 @@ If you are having issues running this command in Windows or Unix, check out the
#### Create a new project
In your terminal, run the CLI command `ng new` with the desired project name. In the following examples, we'll be using the example project name of `my-first-angular-app`.
In your terminal, run the CLI command [`ng new`](cli/new) with the desired project name. In the following examples, we'll be using the example project name of `my-first-angular-app`.
```shell
ng new <project-name>
@@ -76,8 +76,8 @@
trusted type support, help protect your users from common vulnerabilities like
cross-site scripting and cross-site request forgery.
</docs-card>
<docs-card title="Keep large teams productive with Vite and esbuild" href="tools/cli/build-system-migration" link="ESBuild and Vite" titleIconName="sensors">
Angular CLI includes a fast, modern build pipeline using Vite and ESBuild. Developers report
<docs-card title="Keep large teams productive with Vite and esbuild" href="tools/cli/build-system-migration" link="Vite and esbuild" titleIconName="sensors">
Angular CLI includes a fast, modern build pipeline using Vite and esbuild. Developers report
building projects with hundreds of thousands of lines of code in less than a minute.
</docs-card>
<docs-card title="Proven in some of Google's largest web apps" titleIconName="sensors">
@@ -108,7 +108,7 @@
<docs-card title="Partnering with other Google technologies" titleIconName="sensors">
<p>Angular partners closely with other Google technologies and teams to improve the web.</p>
<p>Our ongoing partnership with Chrome’s Aurora actively explores improvements to user experience across the web, developing built-in performance optimizations like <code>NgOptimizedImage</code> and improvements to Angular’s Core Web Vitals.</p>
<p>We are also working with <a href="https://firebase.google.com/">Firebase</a>, <a href="https://www.tensorflow.org/">Tensorflow</a>, <a href="https://flutter.dev/">Flutter</a>, <a href="https://m3.material.io/">Material Design</a>, and <a href="https://cloud.google.com/">Google Cloud</a> to ensure we provide meaningful integrations across the developer workflow.</p>
<p>We are also working with <a href="https://firebase.google.com/" target="_blank">Firebase</a>, <a href="https://www.tensorflow.org/" target="_blank">TensorFlow</a>, <a href="https://flutter.dev/" target="_blank">Flutter</a>, <a href="https://m3.material.io/" target="_blank">Material Design</a>, and <a href="https://cloud.google.com/" target="_blank">Google Cloud</a> to ensure we provide meaningful integrations across the developer workflow.</p>
</docs-card>
</docs-card-container>
@@ -222,6 +222,11 @@ When `true`, reports an error if a component, directive, or pipe is not standalo
When `true`, prints extra information while compiling templates.
Default is `false`.
### `typeCheckHostBindings`
When `true`, enables type checking of expressions in the `host` object literal and `@HostBinding`/`@HostListener` decorators of components and directives.
Default is `true`.
## Command line options
Most of the time, you interact with the Angular Compiler indirectly using [Angular CLI](reference/configs/angular-compiler-options). When debugging certain issues, you might find it useful to invoke the Angular Compiler directly.
@@ -62,14 +62,46 @@ The following properties are a set of options that customize the Angular CLI.
The following top-level configuration properties are available for each project, under `projects['project-name']`.
| Property | Details | Value type | Default value |
| :------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | :-------------- |
| `root` | The root directory for this project's files, relative to the workspace directory. Empty for the initial application, which resides at the top level of the workspace. | `string` | None (required) |
| `projectType` | One of "application" or "library" An application can run independently in a browser, while a library cannot. | `application` \| `library` | None (required) |
| `sourceRoot` | The root directory for this project's source files. | `string` | `''` |
| `prefix` | A string that Angular prepends to selectors when generating new components, directives, and pipes using `ng generate`. Can be customized to identify an application or feature area. | `string` | `'app'` |
| `schematics` | A set of schematics that customize the `ng generate` sub-command option defaults for this project. See the [Generation schematics](#schematics) section. | See [schematics](#schematics) | `{}` |
| `architect` | Configuration defaults for Architect builder targets for this project. | See [Configuring builder targets](#configuring-builder-targets) | `{}` |
| Property | Details | Value type | Default value |
| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | :-------------- |
| `root` | The root directory for this project's files, relative to the workspace directory. Empty for the initial application, which resides at the top level of the workspace. | `string` | None (required) |
| `projectType` | One of "application" or "library" An application can run independently in a browser, while a library cannot. | `application` \| `library` | None (required) |
| `sourceRoot` | The root directory for this project's source files. | `string` | `''` |
| `prefix` | A string that Angular prepends to selectors when generating new components, directives, and pipes using `ng generate`. Can be customized to identify an application or feature area. | `string` | `'app'` |
| `i18n` | Internationalization options for the project. Defines the source locale and additional locales to build. See [Define locales in the build configuration](guide/i18n/merge#define-locales-in-the-build-configuration). | See [i18n options](#i18n-options) | `{}` |
| `schematics` | A set of schematics that customize the `ng generate` sub-command option defaults for this project. See the [Generation schematics](#schematics) section. | See [schematics](#schematics) | `{}` |
| `architect` | Configuration defaults for Architect builder targets for this project. | See [Configuring builder targets](#configuring-builder-targets) | `{}` |
## i18n options
Use the `i18n` project option to define the application's source locale and any additional locales to build.
| Property | Details | Value type | Default value |
| :------------- | :----------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :------------ |
| `sourceLocale` | The locale used in the application source code. Can be a locale identifier string or a [configuration object](#sourcelocale-object). | `string` \| [sourceLocale object](#sourcelocale-object) | `"en-US"` |
| `locales` | A map of locale identifiers to translation files or [locale configuration objects](#locale-object). | `object` | `{}` |
### `sourceLocale` object
Pass an object instead of a string to customize the output directory or base HREF for the source locale:
| Property | Details | Value type | Default value |
| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | :------------ |
| `code` | The source locale identifier. | `string` | `"en-US"` |
| `baseHref` | Overrides the HTML `<base href>` for this locale. The output directory name stays as the locale code. Cannot be used together with `subPath`. | `string` | Locale code |
| `subPath` | Sets both the output directory name and the HTML `<base href>` for this locale. Cannot be used together with `baseHref`. | `string` | Locale code |
### Locale object
Each `locales` entry can be a path string, an array of paths, or an object:
| Property | Details | Value type | Default value |
| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- | :------------ |
| `translation` | Path or paths to the translation file(s) for this locale. | `string` \| `string[]` | |
| `baseHref` | Overrides the HTML `<base href>` for this locale. The output directory name stays as the locale identifier. Cannot be used together with `subPath`. | `string` | Locale code |
| `subPath` | Sets both the output directory name and the HTML `<base href>` for this locale. Cannot be used together with `baseHref`. | `string` | Locale code |
HELPFUL: Use `subPath` rather than `baseHref` when you also need to rename the output directory — for example, to output to `de-DE/` instead of `de/`.
## Schematics
+1 -2
View File
@@ -97,9 +97,8 @@ The following table provides the status for Angular versions under support.
| :------ | :----- | :--------- | :---------- | :--------- |
| ^21.0.0 | Active | 2025-11-19 | 2026-05-19 | 2027-05-19 |
| ^20.0.0 | LTS | 2025-05-28 | 2025-11-19 | 2026-11-28 |
| ^19.0.0 | LTS | 2024-11-19 | 2025-05-28 | 2026-05-19 |
Angular versions v2 to v18 are no longer supported.
Angular versions v2 to v19 are no longer supported.
### LTS fixes
+3 -3
View File
@@ -109,7 +109,7 @@ In Angular v19 we shipped initial support for CSS and template HMR and in v20 we
<docs-card title="Zoneless Angular" link="Completed in Q4 2025">
In v18 we shipped experimental zoneless support in Angular. It enables developers to use the framework without including zone.js in their bundle, which improves performance, debugging experience, and interoperability. As part of the initial release we also introduced zoneless support to the Angular CDK and Angular Material.
In v19 we introduced zoneless support in server-side rendering, addressed some edge cases, and created a schematic to scaffold zoneless projects. We transitioned <a href="https://fonts.google.com/">Google Fonts</a> to zoneless which improved performance, developer experience, and allowed us to identify gaps that we need to address before moving this feature to developer preview.
In v19 we introduced zoneless support in server-side rendering, addressed some edge cases, and created a schematic to scaffold zoneless projects. We transitioned <a href="https://fonts.google.com/" target="_blank">Google Fonts</a> to zoneless which improved performance, developer experience, and allowed us to identify gaps that we need to address before moving this feature to developer preview.
As of Angular v20.2, Zoneless Angular is now stable and includes improvements in error handling and server-side rendering.
</docs-card>
@@ -231,7 +231,7 @@ We will work on finding a way to implement stricter type checking for reactive f
<docs-card title="Improve integration of Angular DevTools with framework" link="Completed Q1 2022" href="tools/devtools">
To improve the integration of Angular DevTools with the framework, we are working on moving the codebase to the angular/angular monorepository. This includes transitioning Angular DevTools to Bazel and integrating it into the existing processes and CI pipeline.
</docs-card>
<docs-card title="Launch advanced compiler diagnostics" link="Completed Q1 2022" href="reference/extended-diagnostics">
<docs-card title="Launch advanced compiler diagnostics" link="Completed Q1 2022" href="extended-diagnostics">
Extend the diagnostics of the Angular compiler outside type checking. Introduce other correctness and conformance checks to further guarantee correctness and best practices.
</docs-card>
<docs-card title="Update our e2e testing strategy" link="Completed Q3 2021" href="guide/testing">
@@ -270,7 +270,7 @@ As part of the v11 release, we introduced an opt-in preview of webpack 5 in the
<docs-card title="Faster apps by inlining critical styles in Universal apps" link="Completed Q1 2021" href="guide/ssr">
Loading external stylesheets is a blocking operation, which means that the browser cannot start rendering your app until it loads all the referenced CSS. Having render-blocking resources in the header of a page can significantly impact its load performance, for example, its first contentful paint. To make apps faster, we have been collaborating with the Google Chrome team on inlining critical CSS and loading the rest of the styles asynchronously.
</docs-card>
<docs-card title="Improve debugging with better Angular error messages" link="Completed Q1 2021" href="reference/errors">
<docs-card title="Improve debugging with better Angular error messages" link="Completed Q1 2021" href="errors">
Error messages often bring limited actionable information to help developers resolve them. We have been working on making error messages more discoverable by adding associated codes, developing guides, and other materials to ensure a smoother debugging experience.
</docs-card>
<docs-card title="Improved developer onboarding with refreshed introductory documentation" link="Completed Q1 2021" href="tutorials">
+1 -1
View File
@@ -68,7 +68,7 @@ To install the Angular CLI, open a terminal window and run the following command
### Powershell execution policy
On Windows client computers, the execution of PowerShell scripts is disabled by default, so the above command may fail with an error.
To allow the execution of PowerShell scripts, which is needed for npm global binaries, you must set the following <a href="https://docs.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies">execution policy</a>:
To allow the execution of PowerShell scripts, which is needed for npm global binaries, you must set the following <a href="https://docs.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies" target="_blank">execution policy</a>:
```sh
@@ -23,7 +23,7 @@ You should be very careful when choosing the name of your library if you want to
See [Publishing your library](tools/libraries/creating-libraries#publishing-your-library).
Avoid using a name that is prefixed with `ng-`, such as `ng-library`.
The `ng-` prefix is a reserved keyword used from the Angular framework and its libraries.
The `ng-` prefix is a reserved keyword used by the Angular framework and its libraries.
The `ngx-` prefix is preferred as a convention used to denote that the library is suitable for use with Angular.
It is also an excellent indication to consumers of the registry to differentiate between libraries of different JavaScript frameworks.
@@ -25,6 +25,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
JSONP_HEADERS_NOT_SUPPORTED = 2812,
// (undocumented)
JSONP_UNSAFE_URL = 2826,
// (undocumented)
JSONP_WRONG_METHOD = 2810,
// (undocumented)
JSONP_WRONG_RESPONSE_TYPE = 2811,
+2
View File
@@ -1739,6 +1739,8 @@ export interface SchemaMetadata {
// @public
export enum SecurityContext {
// (undocumented)
ATTRIBUTE_NO_BINDING = 6,
// (undocumented)
HTML = 1,
// (undocumented)
@@ -13,6 +13,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
HYDRATION_CONFLICTING_FEATURES = 5001,
// (undocumented)
INVALID_EVENT_ATTRIBUTE = 5203,
// (undocumented)
NO_PLUGIN_FOR_EVENT = 5101,
// (undocumented)
ROOT_NODE_NOT_FOUND = -5104,
@@ -61,6 +61,24 @@ export function renderModule<T>(moduleType: Type<T>, options: {
allowedHosts?: Readonly<string>[];
}): Promise<string>;
// @public
export const enum RuntimeErrorCode {
// (undocumented)
DISABLED_DOM_EMULATION_IN_NON_BROWSER = 5704,
// (undocumented)
GET_COOKIE_NOT_IMPLEMENTED = 5700,
// (undocumented)
HOST_NOT_ALLOWED = 5706,
// (undocumented)
INVALID_URL = 5701,
// (undocumented)
PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 5702,
// (undocumented)
SUSPICIOUS_URL_CHANGE_ORIGIN = 5703,
// (undocumented)
XHR_NOT_LOADED = 5705
}
// @public
export class ServerModule {
// (undocumented)
@@ -1,4 +1,4 @@
{
"dist/main.js": 135813,
"dist/main.js": 144843,
"dist/polyfills.js": 35883
}
+1 -1
View File
@@ -15,7 +15,7 @@
"zone.js": "0.16.0"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.29.7",
"@rollup/plugin-babel": "^6.0.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@types/jasmine": "^6.0.0",
@@ -1,5 +1,5 @@
{
"dist/browser/main-[hash].js": 227093,
"dist/browser/polyfills-[hash].js": 34544,
"dist/browser/main-[hash].js": 233303,
"dist/browser/polyfills-[hash].js": 35677,
"dist/browser/event-dispatch-contract.min.js": 476
}
@@ -27,7 +27,9 @@ export class JsonpCmp {
people: Person[] = [];
constructor(http: HttpClient) {
http.jsonp('./people.json', 'callback').subscribe((res: unknown) => {
const peopleUrl = new URL('./people.json', window.location.href).toString();
http.jsonp(peopleUrl, 'callback').subscribe((res: unknown) => {
this.people = res as Person[];
});
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "angular-srcs",
"version": "21.2.13",
"version": "21.2.22",
"private": true,
"description": "Angular - a web framework for modern web apps",
"homepage": "https://github.com/angular/angular",
@@ -76,9 +76,9 @@
"@angular/service-worker": "workspace:*",
"@angular/ssr": "21.2.9",
"@angular/upgrade": "workspace: *",
"@babel/cli": "7.28.6",
"@babel/core": "7.29.0",
"@babel/generator": "7.29.1",
"@babel/cli": "7.29.7",
"@babel/core": "7.29.7",
"@babel/generator": "7.29.7",
"@jridgewell/sourcemap-codec": "^1.4.14",
"@microsoft/api-extractor": "^7.24.2",
"@rollup/plugin-babel": "^6.0.0",
@@ -116,7 +116,7 @@
"d3": "^7.0.0",
"dagre-d3-es": "^7.0.14",
"diff": "^8.0.0",
"domino": "https://github.com/angular/domino.git#928dffb9d9431b2cd2a73d7b940d1575f221e072",
"domino": "https://github.com/angular/domino.git#04f987dc08ff3736b427f50941adf1722458528f",
"esbuild": "0.27.2",
"esbuild-plugin-umd-wrapper": "^3.0.0",
"http-server": "^14.0.0",
@@ -163,7 +163,7 @@
"@actions/github": "^9.0.0",
"@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#4de8a14a1682d0f07e0b14a3b26498757c195904",
"@babel/plugin-proposal-async-generator-functions": "7.20.7",
"@babel/plugin-transform-async-generator-functions": "^7.27.1",
"@babel/plugin-transform-async-generator-functions": "^7.29.7",
"@bazel/bazelisk": "^1.7.5",
"@bazel/buildifier": "^8.0.0",
"@bazel/ibazel": "0.28.0",
+8 -4
View File
@@ -100,11 +100,15 @@ export class HttpInterceptorHandler implements HttpHandler {
handle(initialRequest: HttpRequest<any>): Observable<HttpEvent<any>> {
if (this.chain === null) {
const parentHandler = this.injector.get(HttpHandler, null, {skipSelf: true});
const isDelegating = parentHandler !== null && this.backend === parentHandler;
const rootInterceptorFns = this.injector.get(
HTTP_ROOT_INTERCEPTOR_FNS,
[],
isDelegating ? {self: true} : undefined,
);
const dedupedInterceptorFns = Array.from(
new Set([
...this.injector.get(HTTP_INTERCEPTOR_FNS),
...this.injector.get(HTTP_ROOT_INTERCEPTOR_FNS, []),
]),
new Set([...this.injector.get(HTTP_INTERCEPTOR_FNS), ...rootInterceptorFns]),
);
// Note: interceptors are wrapped right-to-left so that final execution order is
+2
View File
@@ -35,4 +35,6 @@ export const enum RuntimeErrorCode {
REFERRER_NOT_SUPPORTED_WITH_XHR = 2821,
INVALID_TIMEOUT_VALUE = 2822,
REFERRER_POLICY_NOT_SUPPORTED_WITH_XHR = 2823,
JSONP_UNSAFE_URL = 2826,
}
+8 -7
View File
@@ -192,10 +192,10 @@ export class HttpHeaders {
private copyFrom(other: HttpHeaders) {
other.init();
Array.from(other.headers.keys()).forEach((key) => {
this.headers.set(key, other.headers.get(key)!);
for (const [key, values] of other.headers.entries()) {
this.headers.set(key, values);
this.normalizedNames.set(key, other.normalizedNames.get(key)!);
});
}
}
private clone(update: Update): HttpHeaders {
@@ -218,21 +218,22 @@ export class HttpHeaders {
return;
}
this.maybeSetNormalizedName(update.name, key);
const base = (update.op === 'a' ? this.headers.get(key) : undefined) || [];
const base = update.op === 'a' ? (this.headers.get(key) || []).slice() : [];
base.push(...value);
this.headers.set(key, base);
break;
case 'd':
const toDelete = update.value as string | undefined;
if (!toDelete) {
const toDelete = update.value;
if (toDelete === undefined) {
this.headers.delete(key);
this.normalizedNames.delete(key);
} else {
const valuesToDelete = Array.isArray(toDelete) ? toDelete : [toDelete];
let existing = this.headers.get(key);
if (!existing) {
return;
}
existing = existing.filter((value) => toDelete.indexOf(value) === -1);
existing = existing.filter((value) => valuesToDelete.indexOf(value) === -1);
if (existing.length === 0) {
this.headers.delete(key);
this.normalizedNames.delete(key);
+2 -1
View File
@@ -20,6 +20,7 @@ import type {HttpHandler} from './backend';
import {HttpRequest} from './request';
import {HttpEvent} from './response';
import {xsrfInterceptorFn} from './xsrf';
/**
* Intercepts and handles an `HttpRequest` or `HttpResponse`.
@@ -200,7 +201,7 @@ export const HTTP_INTERCEPTORS = new InjectionToken<readonly HttpInterceptor[]>(
*/
export const HTTP_INTERCEPTOR_FNS = new InjectionToken<readonly HttpInterceptorFn[]>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'HTTP_INTERCEPTOR_FNS' : '',
{factory: () => []},
{factory: () => [xsrfInterceptorFn]},
);
/**
+12
View File
@@ -55,6 +55,10 @@ export const JSONP_ERR_WRONG_RESPONSE_TYPE = 'JSONP requests must use Json respo
// headers set
export const JSONP_ERR_HEADERS_NOT_SUPPORTED = 'JSONP requests do not support headers.';
// Error text given when a JSONP request URL is not absolute HTTP(S).
export const JSONP_ERR_UNSAFE_URL =
'JSONP requests only support absolute URLs with HTTP(S) protocols.';
/**
* DI token/abstract type representing a map of JSONP callbacks.
*
@@ -139,6 +143,10 @@ export class JsonpClientBackend implements HttpBackend {
);
}
if (!this.isAllowedJsonpUrl(req.urlWithParams)) {
throw new RuntimeError(RuntimeErrorCode.JSONP_UNSAFE_URL, ngDevMode && JSONP_ERR_UNSAFE_URL);
}
// Everything else happens inside the Observable boundary.
return new Observable<HttpEvent<any>>((observer: Observer<HttpEvent<any>>) => {
// The first step to make a request is to generate the callback name, and replace the
@@ -282,6 +290,10 @@ export class JsonpClientBackend implements HttpBackend {
foreignDocument.adoptNode(script);
}
private isAllowedJsonpUrl(url: string): boolean {
return /^https?:\/\//i.test(url);
}
}
/**
+7 -3
View File
@@ -324,18 +324,22 @@ export class HttpParams {
}
if (this.cloneFrom !== null) {
this.cloneFrom.init();
this.cloneFrom.keys().forEach((key) => this.map!.set(key, this.cloneFrom!.map!.get(key)!));
for (const [key, values] of this.cloneFrom.map!.entries()) {
this.map!.set(key, values);
}
this.updates!.forEach((update) => {
switch (update.op) {
case 'a':
case 's':
const base = (update.op === 'a' ? this.map!.get(update.param) : undefined) || [];
const base = update.op === 'a' ? (this.map!.get(update.param) || []).slice() : [];
base.push(valueToString(update.value!));
this.map!.set(update.param, base);
break;
case 'd':
if (update.value !== undefined) {
let base = this.map!.get(update.param) || [];
const base = (this.map!.get(update.param) || []).slice();
const idx = base.indexOf(valueToString(update.value));
if (idx !== -1) {
base.splice(idx, 1);
+12 -3
View File
@@ -101,9 +101,16 @@ export function provideHttpClient(
featureKinds.has(HttpFeatureKind.CustomXsrfConfiguration)
) {
throw new Error(
ngDevMode
? `Configuration error: found both withXsrfConfiguration() and withNoXsrfProtection() in the same call to provideHttpClient(), which is a contradiction.`
: '',
`Configuration error: found both withXsrfConfiguration() and withNoXsrfProtection() in the same call to provideHttpClient(), which is a contradiction.`,
);
}
if (
featureKinds.has(HttpFeatureKind.RequestsMadeViaParent) &&
featureKinds.has(HttpFeatureKind.Fetch)
) {
throw new Error(
`Configuration error: withRequestsMadeViaParent() cannot be combined with withFetch() in the same call to provideHttpClient().`,
);
}
}
@@ -260,6 +267,8 @@ export function withJsonpSupport(): HttpFeature<HttpFeatureKind.JsonpSupport> {
* "bubble up" until either reaching the root level or an `HttpClient` which was not configured with
* this option.
*
* This feature cannot be combined with `withFetch` in the same `provideHttpClient()` call.
*
* @see [HTTP client setup](guide/http/setup#withrequestsmadeviaparent)
* @see {@link provideHttpClient}
* @publicApi 19.0
+3 -2
View File
@@ -523,7 +523,7 @@ export class HttpRequest<T> {
this.integrity = options.integrity;
}
if (options.referrer) {
if (options.referrer !== undefined) {
this.referrer = options.referrer;
}
@@ -734,7 +734,8 @@ export class HttpRequest<T> {
const mode = update.mode || this.mode;
const redirect = update.redirect || this.redirect;
const credentials = update.credentials || this.credentials;
const referrer = update.referrer || this.referrer;
const referrer = update.referrer ?? this.referrer;
const integrity = update.integrity || this.integrity;
const referrerPolicy = update.referrerPolicy || this.referrerPolicy;
// Carefully handle the transferCache to differentiate between
+318 -117
View File
@@ -40,8 +40,10 @@ import {HttpParams} from './params';
* @param includePostRequests Enables caching for POST requests. By default, only GET and HEAD
* requests are cached. This option can be enabled if POST requests are used to retrieve data
* (for example using GraphQL).
* @param includeRequestsWithAuthHeaders Enables caching of requests containing either `Authorization`
* or `Proxy-Authorization` headers. By default, these requests are excluded from caching.
* @param includeRequestsWithAuthHeaders Enables caching of requests containing `Authorization`,
* `Proxy-Authorization`, or `Cookie` headers. By default, these requests are excluded from
* caching. Requests sent using `withCredentials` or Fetch API `credentials` modes that can send
* credentials are also excluded by default.
*
* @see [Configuring the caching options](guide/ssr#configuring-the-caching-options)
*
@@ -101,13 +103,13 @@ interface TransferHttpResponse {
/** headers */
[HEADERS]: Record<string, string[]>;
/** status */
[STATUS]?: number;
[STATUS]: number;
/** statusText */
[STATUS_TEXT]?: string;
[STATUS_TEXT]: string;
/** url */
[REQ_URL]?: string;
[REQ_URL]: string;
/** responseType */
[RESPONSE_TYPE]?: HttpRequest<unknown>['responseType'];
[RESPONSE_TYPE]: HttpRequest<unknown>['responseType'];
}
interface CacheOptions extends HttpTransferCacheOptions {
@@ -123,18 +125,24 @@ export const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
*/
const ALLOWED_METHODS = ['GET', 'HEAD'];
function shouldCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean {
function canUseOrCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean {
const {isCacheActive, ...globalOptions} = options;
const {transferCache: requestOptions, method: requestMethod} = req;
if (
!isCacheActive ||
requestOptions === false ||
// Do not cache requests sent with credentials.
hasOutgoingCredentials(req) ||
// POST requests are allowed either globally or at request level
(requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) ||
(requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) ||
// Do not cache request that require authorization when includeRequestsWithAuthHeaders is falsey
// Do not cache requests with authentication or cookie headers unless explicitly enabled.
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
// Do not cache requests that explicitly forbid caching via Cache-Control
// or Fetch API cache mode.
hasUncacheableCacheControl(req.headers) ||
isNonCacheableRequest(req.cache) ||
globalOptions.filter?.(req) === false
) {
return false;
@@ -147,25 +155,30 @@ function getHeadersToInclude(
options: CacheOptions,
requestOptions: HttpTransferCacheOptions | boolean | undefined,
): string[] | undefined {
const {includeHeaders: globalHeaders} = options;
let headersToInclude = globalHeaders;
if (typeof requestOptions === 'object' && requestOptions.includeHeaders) {
// Request-specific config takes precedence over the global config.
headersToInclude = requestOptions.includeHeaders;
}
return headersToInclude;
// Request-specific config takes precedence over the global config.
return typeof requestOptions === 'object' && requestOptions.includeHeaders
? requestOptions.includeHeaders
: options.includeHeaders;
}
/**
* Retrieves the cached response for a given request.
* @param req The request to retrieve the cached response for.
* @param options The caching options.
* @param transferState The transfer state to retrieve the cached response from.
* @param originMap The origin map to map the request URL to the origin. (Not needed when `storeKey` is provided).
* @param storeKey The key to use to store the cached response in the transfer state. (If not provided, it will be computed from the request and originMap).
* @param skipUseCacheChecks Whether to skip the use cache checks. (Only disable when the checks have been performed beforehand).
*/
export function retrieveStateFromCache(
req: HttpRequest<unknown>,
options: CacheOptions,
transferState: TransferState,
originMap: Record<string, string> | null,
storeKey?: StateKey<TransferHttpResponse>,
skipUseCacheChecks = false,
): HttpResponse<unknown> | null {
const {transferCache: requestOptions} = req;
// In the following situations we do not want to cache the request
if (!shouldCacheRequest(req, options)) {
if (!skipUseCacheChecks && !canUseOrCacheRequest(req, options)) {
return null;
}
@@ -179,58 +192,61 @@ export function retrieveStateFromCache(
);
}
const requestUrl =
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
? mapRequestOriginUrl(req.url, originMap)
: req.url;
if (!storeKey) {
const requestUrl =
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
? mapRequestOriginUrl(req.url, originMap)
: req.url;
const storeKey = makeCacheKey(req, requestUrl);
const response = transferState.get(storeKey, null);
const headersToInclude = getHeadersToInclude(options, requestOptions);
if (response) {
const {
[BODY]: undecodedBody,
[RESPONSE_TYPE]: responseType,
[HEADERS]: httpHeaders,
[STATUS]: status,
[STATUS_TEXT]: statusText,
[REQ_URL]: url,
} = response;
// Request found in cache. Respond using it.
let body: ArrayBuffer | Blob | string | undefined = undecodedBody;
switch (responseType) {
case 'arraybuffer':
body = fromBase64(undecodedBody);
break;
case 'blob':
body = new Blob([fromBase64(undecodedBody)]);
break;
}
// We want to warn users accessing a header provided from the cache
// That HttpTransferCache alters the headers
// The warning will be logged a single time by HttpHeaders instance
let headers = new HttpHeaders(httpHeaders);
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// Append extra logic in dev mode to produce a warning when a header
// that was not transferred to the client is accessed in the code via `get`
// and `has` calls.
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
}
return new HttpResponse({
body,
headers,
status,
statusText,
url,
});
storeKey = makeCacheKey(req, requestUrl);
}
return null;
const response = transferState.get(storeKey, null);
if (!response) {
return null;
}
const {
[BODY]: undecodedBody,
[RESPONSE_TYPE]: responseType,
[HEADERS]: httpHeaders,
[STATUS]: status,
[STATUS_TEXT]: statusText,
[REQ_URL]: url,
} = response;
// Request found in cache. Respond using it.
let body: ArrayBuffer | Blob | string | undefined = undecodedBody;
switch (responseType) {
case 'arraybuffer':
body = fromBase64(undecodedBody);
break;
case 'blob':
body = new Blob([fromBase64(undecodedBody)]);
break;
}
// We want to warn users accessing a header provided from the cache
// That HttpTransferCache alters the headers
// The warning will be logged a single time by HttpHeaders instance
let headers = new HttpHeaders(httpHeaders);
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// Append extra logic in dev mode to produce a warning when a header
// that was not transferred to the client is accessed in the code via `get`
// and `has` calls.
const {transferCache: requestOptions} = req;
const headersToInclude = getHeadersToInclude(options, requestOptions);
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
}
return new HttpResponse({
body,
headers,
status,
statusText,
url,
});
}
export function transferCacheInterceptorFn(
@@ -238,47 +254,58 @@ export function transferCacheInterceptorFn(
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
const options = inject(CACHE_OPTIONS);
const transferState = inject(TransferState);
const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true});
const cachedResponse = retrieveStateFromCache(req, options, transferState, originMap);
if (cachedResponse) {
return of(cachedResponse);
if (!canUseOrCacheRequest(req, options)) {
return next(req);
}
const {transferCache: requestOptions} = req;
const headersToInclude = getHeadersToInclude(options, requestOptions);
const transferState = inject(TransferState);
const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true});
const requestUrl =
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
? mapRequestOriginUrl(req.url, originMap)
: req.url;
const storeKey = makeCacheKey(req, requestUrl);
// In the following situations we do not want to cache the request
if (!shouldCacheRequest(req, options)) {
return next(req);
const cachedResponse = retrieveStateFromCache(
req,
options,
transferState,
/** originMap */ null,
storeKey,
/** skipUseCacheChecks */ true,
);
if (cachedResponse) {
return of(cachedResponse);
}
const event$ = next(req);
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
// Request not found in cache. Make the request and cache it if on the server.
return event$.pipe(
tap((event: HttpEvent<unknown>) => {
// Only cache successful HTTP responses.
if (event instanceof HttpResponse) {
const {headers, body, status, statusText} = event;
// Only cache successful HTTP responses that do not have Cache-Control
// directives that forbid shared caching (no-store or private) and do not
// carry a Set-Cookie header. A Set-Cookie header marks the response as
// user-specific.
if (hasUncacheableCacheControl(headers) || hasSetCookieHeader(headers)) {
return;
}
const {transferCache: requestOptions, responseType} = req;
const headersToInclude = getHeadersToInclude(options, requestOptions);
transferState.set<TransferHttpResponse>(storeKey, {
[BODY]:
req.responseType === 'arraybuffer' || req.responseType === 'blob'
? toBase64(event.body)
: event.body,
[HEADERS]: getFilteredHeaders(event.headers, headersToInclude),
[STATUS]: event.status,
[STATUS_TEXT]: event.statusText,
responseType === 'arraybuffer' || responseType === 'blob' ? toBase64(body) : body,
[HEADERS]: getFilteredHeaders(headers, headersToInclude),
[STATUS]: status,
[STATUS_TEXT]: statusText,
[REQ_URL]: requestUrl,
[RESPONSE_TYPE]: req.responseType,
[RESPONSE_TYPE]: responseType,
});
}
}),
@@ -288,9 +315,43 @@ export function transferCacheInterceptorFn(
return event$;
}
/** @returns true when the requests contains autorization related headers. */
/** @returns true when the request contains authentication or cookie headers. */
function hasAuthHeaders(req: HttpRequest<unknown>): boolean {
return req.headers.has('authorization') || req.headers.has('proxy-authorization');
const headers = req.headers;
return (
headers.has('authorization') || headers.has('proxy-authorization') || headers.has('cookie')
);
}
function hasOutgoingCredentials(req: HttpRequest<unknown>): boolean {
const {withCredentials, credentials} = req;
return withCredentials || credentials === 'include' || credentials === 'same-origin';
}
const UNCACHEABLE_CACHE_CONTROL_DIRECTIVES = new Set(['no-store', 'private', 'no-cache']);
function hasUncacheableCacheControl(headers: HttpHeaders): boolean {
const cacheControl = headers.get('cache-control');
if (!cacheControl) {
return false;
}
return cacheControl.split(',').some((directive) => {
const directiveName = directive.split('=', 1)[0].trim().toLowerCase();
return UNCACHEABLE_CACHE_CONTROL_DIRECTIVES.has(directiveName);
});
}
function hasSetCookieHeader(headers: HttpHeaders): boolean {
return headers.has('set-cookie');
}
function isNonCacheableRequest(cache: RequestCache): boolean {
return cache === 'no-cache' || cache === 'no-store';
}
function getFilteredHeaders(
@@ -313,17 +374,17 @@ function getFilteredHeaders(
}
function sortAndConcatParams(params: HttpParams | URLSearchParams): string {
return [...params.keys()]
.sort()
.map((k) => `${k}=${params.getAll(k)}`)
.join('&');
const searchParams = new URLSearchParams(
params instanceof URLSearchParams ? params : params.toString(),
);
searchParams.sort();
return searchParams.toString();
}
function makeCacheKey(
request: HttpRequest<any>,
mappedRequestUrl: string,
): StateKey<TransferHttpResponse> {
// make the params encoded same as a url so it's easy to identify
const {params, method, responseType} = request;
const encodedParams = sortAndConcatParams(params);
@@ -340,26 +401,6 @@ function makeCacheKey(
return makeStateKey(hash);
}
/**
* A method that returns a hash representation of a string using a variant of DJB2 hash
* algorithm.
*
* This is the same hashing logic that is used to generate component ids.
*/
function generateHash(value: string): string {
let hash = 0;
for (const char of value) {
hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0;
}
// Force positive number hash.
// 2147483647 = equivalent of Integer.MAX_VALUE.
hash += 2147483647 + 1;
return hash.toString();
}
function toBase64(buffer: unknown): string {
//TODO: replace with when is Baseline widely available
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64
@@ -497,3 +538,163 @@ function verifyMappedOrigin(url: string): void {
);
}
}
/**
* SHA-256 Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes 2..311):
*/
const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
let textEncoder: TextEncoder | undefined;
/**
* Generates a SHA-256 hash representation of a string.
*
* Note: A custom synchronous SHA-256 implementation is used here because the Web Crypto API
* (`crypto.subtle.digest`) is strictly asynchronous (Promise-based), whereas the transfer cache
* state lookup and interceptor flow must operate synchronously due to the HttpResource API.
*
* The previous DJB2 hashing logic was vulnerable to pre-image and second-preimage attacks due to
* its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request
* inputs to poison the cache, potentially causing a CDN or the application to serve the wrong
* cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance,
* preventing cache key collision attacks.
*/
export function generateHash(value: string): string {
textEncoder ??= new TextEncoder();
const inputBytes = textEncoder.encode(value);
// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19):
let hashState0 = 0x6a09e667;
let hashState1 = 0xbb67ae85;
let hashState2 = 0x3c6ef372;
let hashState3 = 0xa54ff53a;
let hashState4 = 0x510e527f;
let hashState5 = 0x9b05688c;
let hashState6 = 0x1f83d9ab;
let hashState7 = 0x5be0cd19;
// Pre-processing (Padding):
const messageLengthInBits = inputBytes.length * 8;
// The total length of the padded message must be a multiple of 64 bytes (512 bits)
const paddedLengthInBytes = (((inputBytes.length + 8) >> 6) + 1) << 6;
const paddedBytes = new Uint8Array(paddedLengthInBytes);
paddedBytes.set(inputBytes);
paddedBytes[inputBytes.length] = 0x80; // Append a single '1' bit (0x80 byte)
const paddedBytesView = new DataView(paddedBytes.buffer);
const lowBits = messageLengthInBits >>> 0;
const highBits = (messageLengthInBits / 0x100000000) >>> 0;
paddedBytesView.setUint32(paddedLengthInBytes - 8, highBits, false);
paddedBytesView.setUint32(paddedLengthInBytes - 4, lowBits, false);
// Process the message in successive 64-byte chunks:
const messageSchedule = new Uint32Array(64);
for (let chunkOffset = 0; chunkOffset < paddedLengthInBytes; chunkOffset += 64) {
// Initialize first 16 words of the message schedule:
for (let i = 0; i < 16; i++) {
messageSchedule[i] = paddedBytesView.getUint32(chunkOffset + i * 4, false);
}
// Extend to 64 words:
for (let i = 16; i < 64; i++) {
const prevWord15 = messageSchedule[i - 15];
const sigma0 =
(((prevWord15 >>> 7) | (prevWord15 << 25)) ^
((prevWord15 >>> 18) | (prevWord15 << 14)) ^
(prevWord15 >>> 3)) >>>
0;
const prevWord2 = messageSchedule[i - 2];
const sigma1 =
(((prevWord2 >>> 17) | (prevWord2 << 15)) ^
((prevWord2 >>> 19) | (prevWord2 << 13)) ^
(prevWord2 >>> 10)) >>>
0;
messageSchedule[i] =
(messageSchedule[i - 16] + sigma0 + messageSchedule[i - 7] + sigma1) >>> 0;
}
// Initialize working variables to current hash values:
let workingStateA = hashState0;
let workingStateB = hashState1;
let workingStateC = hashState2;
let workingStateD = hashState3;
let workingStateE = hashState4;
let workingStateF = hashState5;
let workingStateG = hashState6;
let workingStateH = hashState7;
// Compression function main loop:
for (let i = 0; i < 64; i++) {
const capitalSigma1 =
(((workingStateE >>> 6) | (workingStateE << 26)) ^
((workingStateE >>> 11) | (workingStateE << 21)) ^
((workingStateE >>> 25) | (workingStateE << 7))) >>>
0;
const chFunction = ((workingStateE & workingStateF) ^ (~workingStateE & workingStateG)) >>> 0;
const temp1 =
(workingStateH +
capitalSigma1 +
chFunction +
SHA256_ROUND_CONSTANTS[i] +
messageSchedule[i]) >>>
0;
const capitalSigma0 =
(((workingStateA >>> 2) | (workingStateA << 30)) ^
((workingStateA >>> 13) | (workingStateA << 19)) ^
((workingStateA >>> 22) | (workingStateA << 10))) >>>
0;
const majFunction =
((workingStateA & workingStateB) ^
(workingStateA & workingStateC) ^
(workingStateB & workingStateC)) >>>
0;
const temp2 = (capitalSigma0 + majFunction) >>> 0;
workingStateH = workingStateG;
workingStateG = workingStateF;
workingStateF = workingStateE;
workingStateE = (workingStateD + temp1) >>> 0;
workingStateD = workingStateC;
workingStateC = workingStateB;
workingStateB = workingStateA;
workingStateA = (temp1 + temp2) >>> 0;
}
// Update intermediate hash state:
hashState0 = (hashState0 + workingStateA) >>> 0;
hashState1 = (hashState1 + workingStateB) >>> 0;
hashState2 = (hashState2 + workingStateC) >>> 0;
hashState3 = (hashState3 + workingStateD) >>> 0;
hashState4 = (hashState4 + workingStateE) >>> 0;
hashState5 = (hashState5 + workingStateF) >>> 0;
hashState6 = (hashState6 + workingStateG) >>> 0;
hashState7 = (hashState7 + workingStateH) >>> 0;
}
// Produce the final 64-character hexadecimal hash:
return [
hashState0,
hashState1,
hashState2,
hashState3,
hashState4,
hashState5,
hashState6,
hashState7,
]
.map((x) => x.toString(16).padStart(8, '0'))
.join('');
}
+2 -2
View File
@@ -16,8 +16,8 @@ import {
} from '@angular/core';
import {Observable} from 'rxjs';
import {HttpHandler} from './backend';
import {HttpHandlerFn, HttpInterceptor} from './interceptor';
import type {HttpHandler} from './backend';
import type {HttpHandlerFn, HttpInterceptor} from './interceptor';
import {HttpRequest} from './request';
import {HttpEvent} from './response';
+37
View File
@@ -154,6 +154,34 @@ describe('HttpHeaders', () => {
const fourth = third.delete('FOO');
expect(fourth.has('foo')).toEqual(false);
});
it('should delete only the exact matching string value', () => {
const headers = new HttpHeaders({
'X-Scopes': ['tenant:alpha', 'tenant:alpha:archive'],
});
const updated = headers.delete('X-Scopes', 'tenant:alpha:archive');
expect(updated.getAll('X-Scopes')).toEqual(['tenant:alpha']);
});
it('should delete only exact matching values from an array', () => {
const headers = new HttpHeaders({
'X-Scopes': ['tenant:alpha', 'tenant:alpha:archive'],
});
const updated = headers.delete('X-Scopes', ['tenant:alpha:archive']);
expect(updated.getAll('X-Scopes')).toEqual(['tenant:alpha']);
});
it('should treat an empty string as a value to delete', () => {
const headers = new HttpHeaders({foo: ['', 'bar']});
const updated = headers.delete('foo', '');
expect(updated.getAll('foo')).toEqual(['bar']);
});
});
describe('.append', () => {
@@ -165,6 +193,15 @@ describe('HttpHeaders', () => {
expect(third.getAll('foo')).toEqual(['bar', 'baz']);
});
it('should not mutate a materialized source', () => {
const source = new HttpHeaders().set('foo', 'bar');
expect(source.getAll('foo')).toEqual(['bar']);
const clone = source.append('foo', 'baz');
expect(clone.getAll('foo')).toEqual(['bar', 'baz']);
expect(source.getAll('foo')).toEqual(['bar']);
});
it('should preserve the case of the first call', () => {
const headers = new HttpHeaders();
const second = headers.append('FOO', 'bar');
+40 -1
View File
@@ -12,6 +12,7 @@ import {HttpHeaders} from '../src/headers';
import {
JSONP_ERR_HEADERS_NOT_SUPPORTED,
JSONP_ERR_NO_CALLBACK,
JSONP_ERR_UNSAFE_URL,
JSONP_ERR_WRONG_METHOD,
JSONP_ERR_WRONG_RESPONSE_TYPE,
JsonpCallbackContext,
@@ -25,7 +26,7 @@ import {toArray} from 'rxjs/operators';
import {MockDocument} from './jsonp_mock';
describe('JsonpClientBackend', () => {
const SAMPLE_REQ = new HttpRequest<never>('JSONP', '/test');
const SAMPLE_REQ = new HttpRequest<never>('JSONP', 'https://example.com/test');
let home: any;
let document: MockDocument;
let backend: JsonpClientBackend;
@@ -127,6 +128,44 @@ describe('JsonpClientBackend', () => {
});
});
describe('URL protocols', () => {
it('allows absolute HTTP(S) URLs', () => {
const urls = [
'http://example.com/test',
'https://example.com/test',
'HTTP://example.com/test',
];
for (const url of urls) {
const subscription = backend.handle(SAMPLE_REQ.clone<never>({url})).subscribe();
subscription.unsubscribe();
}
});
it('rejects URLs without absolute HTTP(S) protocols before creating a script element', () => {
const urls = [
'//example.com/test',
'/test',
'test',
'data:text/javascript,alert(1)',
'blob:https://example.com/jsonp',
'javascript:alert(1)',
'file:///tmp/jsonp.js',
'filesystem:https://example.com/temporary/jsonp.js',
'ftp://example.com/jsonp.js',
'custom-scheme://example.com/jsonp.js',
];
for (const url of urls) {
expect(() => backend.handle(SAMPLE_REQ.clone<never>({url}))).toThrowError(
`NG02826: ${JSONP_ERR_UNSAFE_URL}`,
);
expect(document.mock).toBeUndefined();
}
});
});
describe('throws an error', () => {
it('when request method is not JSONP', () =>
expect(() => backend.handle(SAMPLE_REQ.clone<never>({method: 'GET'}))).toThrowError(
+18
View File
@@ -72,6 +72,15 @@ describe('HttpUrlEncodedParams', () => {
expect(mutated.toString()).toEqual('a=b&a=true');
});
it('should not mutate a materialized source when appending', () => {
const source = new HttpParams().append('a', 'b');
expect(source.getAll('a')).toEqual(['b']);
const clone = source.append('a', 'c');
expect(clone.getAll('a')).toEqual(['b', 'c']);
expect(source.getAll('a')).toEqual(['b']);
});
it('should allow appending all string parameters', () => {
const body = new HttpParams({fromString: 'a=a1&b=b1'});
const mutated = body.appendAll({a: ['a2', 'a3'], b: 'b2'});
@@ -138,6 +147,15 @@ describe('HttpUrlEncodedParams', () => {
expect(mutated.getAll('a')).toEqual(['1', '3', '5']);
});
it('should not mutate a materialized source when deleting a value', () => {
const source = new HttpParams().append('a', '1').append('a', '2');
expect(source.getAll('a')).toEqual(['1', '2']);
const clone = source.delete('a', '1');
expect(clone.getAll('a')).toEqual(['2']);
expect(source.getAll('a')).toEqual(['1', '2']);
});
it('should allow deletion of one value of a number parameter', () => {
const body = new HttpParams({fromString: 'a=0&a=1&a=2&a=3&a=4&a=5'});
const mutated = body.delete('a', 0).delete('a', 4);
+183 -8
View File
@@ -34,8 +34,9 @@ import {
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {EMPTY, Observable, from} from 'rxjs';
import {tap} from 'rxjs/operators';
import {HttpInterceptorFn} from '../src/interceptor';
import {HTTP_ROOT_INTERCEPTOR_FNS, HttpInterceptorFn} from '../src/interceptor';
import {
HttpFeature,
HttpFeatureKind,
@@ -264,6 +265,19 @@ describe('provideHttpClient', () => {
});
describe('xsrf protection', () => {
it('should enable xsrf protection for the root-provided HttpClient', () => {
TestBed.configureTestingModule({
providers: [provideHttpClientTesting(), {provide: PLATFORM_ID, useValue: 'test'}],
});
setXsrfToken('abcdefg');
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('abcdefg');
req.flush('');
});
it('should enable xsrf protection by default', () => {
TestBed.configureTestingModule({
providers: [
@@ -314,7 +328,7 @@ describe('provideHttpClient', () => {
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.has('X-Custom-Xsrf-Header')).toBeFalse();
expect(req.request.headers.has('X-XSRF-TOKEN')).toBeFalse();
req.flush('');
});
@@ -359,6 +373,12 @@ describe('provideHttpClient', () => {
});
describe('withRequestsMadeViaParent()', () => {
it('should error when combined with a backend override', () => {
expect(() => provideHttpClient(withRequestsMadeViaParent(), withFetch())).toThrowError(
'Configuration error: withRequestsMadeViaParent() cannot be combined with withFetch() in the same call to provideHttpClient().',
);
});
for (const backend of ['fetch', 'xhr']) {
describe(`given '${backend}' backend`, () => {
const commonHttpFeatures: HttpFeature<HttpFeatureKind>[] = [];
@@ -436,18 +456,59 @@ describe('provideHttpClient', () => {
req.flush('');
});
it('should be able to connect to a legacy-provided HttpClient context', () => {
it('should include root interceptors in independent child contexts', () => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [provideLegacyInterceptor('parent')],
providers: [
provideHttpClient(...commonHttpFeatures),
{
provide: HTTP_ROOT_INTERCEPTOR_FNS,
useValue: makeLiteralTagInterceptorFn('root'),
multi: true,
},
provideHttpClientTesting(),
],
});
const child = createEnvironmentInjector(
[
provideHttpClient(withInterceptors([makeLiteralTagInterceptorFn('child')])),
{
provide: HttpBackend,
useFactory: () => inject(HttpBackend, {skipSelf: true}),
},
],
TestBed.inject(EnvironmentInjector),
);
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
req.flush('');
});
it('should run root interceptors once in the parent request and response chain', () => {
const order: string[] = [];
TestBed.configureTestingModule({
providers: [
provideHttpClient(
...commonHttpFeatures,
withInterceptors([makeOrderedTagInterceptorFn('parent', order)]),
),
{
provide: HTTP_ROOT_INTERCEPTOR_FNS,
useValue: makeOrderedTagInterceptorFn('root', order),
multi: true,
},
provideHttpClientTesting(),
],
});
const child = createEnvironmentInjector(
[
provideHttpClient(
...commonHttpFeatures,
withRequestsMadeViaParent(),
withInterceptors([makeLiteralTagInterceptorFn('child')]),
withInterceptors([makeOrderedTagInterceptorFn('child', order)]),
),
],
TestBed.inject(EnvironmentInjector),
@@ -455,11 +516,112 @@ describe('provideHttpClient', () => {
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
expect(req.request.headers.get('X-Tag')).toEqual('child,parent,root');
expect(order).toEqual(['child:request', 'parent:request', 'root:request']);
req.flush('');
expect(order).toEqual([
'child:request',
'parent:request',
'root:request',
'root:response',
'parent:response',
'child:response',
]);
});
});
}
it('should be able to connect to a legacy-provided HttpClient context', () => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [provideLegacyInterceptor('parent')],
});
const child = createEnvironmentInjector(
[
provideHttpClient(
withRequestsMadeViaParent(),
withInterceptors([makeLiteralTagInterceptorFn('child')]),
),
],
TestBed.inject(EnvironmentInjector),
);
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
req.flush('');
});
it('should inherit root interceptors in independent child contexts', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
{
provide: HTTP_ROOT_INTERCEPTOR_FNS,
useValue: makeLiteralTagInterceptorFn('root'),
multi: true,
},
provideHttpClientTesting(),
],
});
const delegatingParent = createEnvironmentInjector(
[provideHttpClient(withRequestsMadeViaParent())],
TestBed.inject(EnvironmentInjector),
);
const independentChild = createEnvironmentInjector(
[
provideHttpClient(withInterceptors([makeLiteralTagInterceptorFn('child')])),
{provide: HttpBackend, useValue: TestBed.inject(HttpBackend)},
],
delegatingParent,
);
try {
independentChild.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
req.flush('');
} finally {
independentChild.destroy();
delegatingParent.destroy();
}
});
it('should inherit root interceptors when a custom provider overrides delegation', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
{
provide: HTTP_ROOT_INTERCEPTOR_FNS,
useValue: makeLiteralTagInterceptorFn('root'),
multi: true,
},
provideHttpClientTesting(),
],
});
const child = createEnvironmentInjector(
[
provideHttpClient(
withRequestsMadeViaParent(),
withInterceptors([makeLiteralTagInterceptorFn('child')]),
),
{provide: HttpBackend, useValue: TestBed.inject(HttpBackend)},
],
TestBed.inject(EnvironmentInjector),
);
try {
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
req.flush('');
} finally {
child.destroy();
}
});
});
describe('compatibility with Http NgModules', () => {
@@ -641,6 +803,19 @@ function makeLiteralTagInterceptorFn(tag: string): HttpInterceptorFn {
return (req, next) => next(addTagToRequest(req, tag));
}
function makeOrderedTagInterceptorFn(tag: string, order: string[]): HttpInterceptorFn {
return (req, next) => {
order.push(`${tag}:request`);
return next(addTagToRequest(req, tag)).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
order.push(`${tag}:response`);
}
}),
);
};
}
function makeTokenTagInterceptorFn(tag: InjectionToken<string>): HttpInterceptorFn {
return (req, next) => next(addTagToRequest(req, inject(tag)));
}
+11
View File
@@ -95,6 +95,10 @@ describe('HttpRequest', () => {
const req = new HttpRequest('GET', '/test', {referrer: 'about:client'});
expect(req.referrer).toBe('about:client');
});
it('should preserve an empty string referrer (used to suppress the Referer header)', () => {
const req = new HttpRequest('GET', '/test', {referrer: ''});
expect(req.referrer).toBe('');
});
it('should allow setting referrerPolicy option', () => {
const req = new HttpRequest('GET', '/test', {referrerPolicy: 'no-referrer'});
expect(req.referrerPolicy).toBe('no-referrer');
@@ -175,6 +179,13 @@ describe('HttpRequest', () => {
it('and updates the referrer', () => {
expect(req.clone({referrer: 'https://example.com'}).referrer).toBe('https://example.com');
});
it('and preserves an existing empty string referrer when the update omits it', () => {
const emptyReferrerReq = new HttpRequest('GET', '/test', {referrer: ''});
expect(emptyReferrerReq.clone().referrer).toBe('');
});
it('and can update the referrer to an empty string', () => {
expect(req.clone({referrer: ''}).referrer).toBe('');
});
it('and updates the integrity', () => {
expect(req.clone({integrity: 'sha512-...'}).integrity).toBe('sha512-...');
});
@@ -10,6 +10,8 @@ import {DOCUMENT} from '../../index';
import {
ApplicationRef,
Component,
createEnvironmentInjector,
EnvironmentInjector,
Injectable,
PLATFORM_ID,
TransferState,
@@ -17,18 +19,30 @@ import {
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {useAutoTick, timeout, withBody} from '@angular/private/testing';
import {BehaviorSubject} from 'rxjs';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {HttpClient, HttpResponse, provideHttpClient} from '../public_api';
import {
HttpClient,
HttpHeaders,
HttpParams,
HttpRequest,
HttpResponse,
provideHttpClient,
withInterceptors,
withRequestsMadeViaParent,
} from '../public_api';
import {
BODY,
CACHE_OPTIONS,
HEADERS,
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
RESPONSE_TYPE,
STATUS,
STATUS_TEXT,
REQ_URL,
transferCacheInterceptorFn,
withHttpTransferCache,
generateHash,
} from '../src/transfer_cache';
import {HttpTestingController, provideHttpClientTesting} from '../testing';
import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '../../src/platform_id';
@@ -38,6 +52,11 @@ interface RequestParams {
observe?: 'body' | 'response';
transferCache?: {includeHeaders: string[]} | boolean;
headers?: {[key: string]: string};
/** Separate response headers for flush(); falls back to headers if not set */
responseHeaders?: {[key: string]: string};
withCredentials?: boolean;
credentials?: RequestCredentials;
cache?: RequestCache;
body?: RequestBody;
}
@@ -60,6 +79,344 @@ describe('TransferCache', () => {
})
class SomeComponent {}
describe('transferCacheInterceptorFn', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
function configureInterceptor(options: {includeRequestsWithAuthHeaders?: boolean} = {}): void {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
TransferState,
{
provide: CACHE_OPTIONS,
useValue: {
isCacheActive: true,
...options,
},
},
],
});
}
function runOnServer<T>(callback: () => T): T {
const previousServerMode = globalThis['ngServerMode'];
globalThis['ngServerMode'] = true;
try {
return callback();
} finally {
globalThis['ngServerMode'] = previousServerMode;
}
}
function runInterceptor(
req: HttpRequest<unknown>,
next: (req: HttpRequest<unknown>) => Observable<HttpResponse<unknown>>,
): HttpResponse<unknown> {
let response!: HttpResponse<unknown>;
TestBed.runInInjectionContext(() => {
transferCacheInterceptorFn(req, next).subscribe((event) => {
if (event instanceof HttpResponse) {
response = event;
}
});
});
return response;
}
it('should not reuse cached responses for Cookie-bearing requests by default', () => {
configureInterceptor();
const firstRequest = new HttpRequest('GET', '/test-cookie', null, {
headers: new HttpHeaders({Cookie: 'session=user-a'}),
});
const secondRequest = new HttpRequest('GET', '/test-cookie', null, {
headers: new HttpHeaders({Cookie: 'session=user-b'}),
});
const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'user-b-secret'})));
runOnServer(() => {
expect(runInterceptor(firstRequest, firstNext).body).toBe('user-a-secret');
expect(runInterceptor(secondRequest, secondNext).body).toBe('user-b-secret');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
it("should preserve opt-in caching for Cookie-bearing requests when 'includeRequestsWithAuthHeaders' is true", () => {
configureInterceptor({includeRequestsWithAuthHeaders: true});
const request = new HttpRequest('GET', '/test-cookie', null, {
headers: new HttpHeaders({Cookie: 'session=user-a'}),
});
const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'network-should-not-run'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('user-a-secret');
expect(runInterceptor(request, secondNext).body).toBe('user-a-secret');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).not.toHaveBeenCalled();
});
it('should not cache responses with Cache-Control: no-store', () => {
configureInterceptor();
const request = new HttpRequest('GET', '/test-no-store');
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
of(
new HttpResponse({
body: 'sensitive-data',
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
}),
),
);
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('sensitive-data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
it('should not cache responses with Cache-Control: private', () => {
configureInterceptor();
const request = new HttpRequest('GET', '/test-private');
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
of(
new HttpResponse({
body: 'user-data',
headers: new HttpHeaders({'Cache-Control': 'private'}),
}),
),
);
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'public-data'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('user-data');
expect(runInterceptor(request, secondNext).body).toBe('public-data');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
it('should not cache responses with a Set-Cookie header', () => {
configureInterceptor();
const request = new HttpRequest('GET', '/test-set-cookie');
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
of(
new HttpResponse({
body: 'user-a-session',
headers: new HttpHeaders({'Set-Cookie': 'session=user-a; HttpOnly'}),
}),
),
);
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'user-b-session'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('user-a-session');
expect(runInterceptor(request, secondNext).body).toBe('user-b-session');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
it('should not cache requests with Cache-Control: no-store', () => {
configureInterceptor();
const request = new HttpRequest('GET', '/test-req-no-store', null, {
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
});
const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'data'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
it('should not cache requests with Cache-Control: no-cache', () => {
configureInterceptor();
const request = new HttpRequest('GET', '/test-req-no-cache', null, {
headers: new HttpHeaders({'Cache-Control': 'no-cache'}),
});
const firstNext = jasmine
.createSpy('firstNext')
.and.returnValue(of(new HttpResponse({body: 'data'})));
const secondNext = jasmine
.createSpy('secondNext')
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
runOnServer(() => {
expect(runInterceptor(request, firstNext).body).toBe('data');
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
});
expect(firstNext).toHaveBeenCalledTimes(1);
expect(secondNext).toHaveBeenCalledTimes(1);
});
});
describe('withRequestsMadeViaParent()', () => {
let childInjector: EnvironmentInjector;
beforeEach(() => {
globalThis['ngServerMode'] = true;
TestBed.configureTestingModule({
providers: [
TransferState,
withHttpTransferCache({
filter: (request) => !request.headers.has('X-API-Key'),
}),
provideHttpClient(
withInterceptors([
(request, next) => {
if (request.url === '/private') {
request = request.clone({
setHeaders: {Authorization: 'Bearer server-token'},
});
} else if (request.url === '/secret') {
request = request.clone({setHeaders: {'X-API-Key': 'secret-a'}});
} else if (request.url === '/public-alias') {
request = request.clone({url: '/public'});
}
return next(request);
},
]),
),
provideHttpClientTesting(),
],
});
childInjector = createEnvironmentInjector(
[
provideHttpClient(
withInterceptors([
(request, next) =>
next(request.clone({setHeaders: {'X-Trace-Id': 'feature-request'}})),
]),
withRequestsMadeViaParent(),
),
],
TestBed.inject(EnvironmentInjector),
);
});
afterEach(() => {
childInjector.destroy();
TestBed.inject(HttpTestingController).verify();
globalThis['ngServerMode'] = undefined;
});
it('should evaluate cache eligibility and keys after parent interceptors', () => {
const httpClient = childInjector.get(HttpClient);
const httpTestingController = TestBed.inject(HttpTestingController);
const transferState = TestBed.inject(TransferState);
let privateResponse: unknown;
httpClient.get('/private').subscribe((response) => (privateResponse = response));
const privateRequest = httpTestingController.expectOne('/private');
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
expect(privateRequest.request.headers.get('X-Trace-Id')).toBe('feature-request');
privateRequest.flush({internalSecret: 'server-only'});
expect(privateResponse).toEqual({internalSecret: 'server-only'});
expect(JSON.parse(transferState.toJson())).toEqual({});
let publicResponse: unknown;
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
httpTestingController.expectOne('/public').flush({message: 'public'});
publicResponse = undefined;
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
httpTestingController.expectNone('/public');
expect(publicResponse).toEqual({message: 'public'});
});
it('should evaluate cache filters after parent interceptors', () => {
const httpClient = childInjector.get(HttpClient);
const httpTestingController = TestBed.inject(HttpTestingController);
const transferState = TestBed.inject(TransferState);
let response: unknown;
httpClient.get('/secret').subscribe((value) => (response = value));
const request = httpTestingController.expectOne('/secret');
expect(request.request.headers.get('X-API-KEY')).toBe('secret-a');
request.flush({internalSecret: 'secret-only'});
expect(response).toEqual({internalSecret: 'secret-only'});
expect(JSON.parse(transferState.toJson())).toEqual({});
});
it('should evaluate cache eligibility through multiple parent clients', () => {
const grandchildInjector = createEnvironmentInjector(
[provideHttpClient(withRequestsMadeViaParent())],
childInjector,
);
try {
const httpClient = grandchildInjector.get(HttpClient);
const httpTestingController = TestBed.inject(HttpTestingController);
const transferState = TestBed.inject(TransferState);
let privateResponse: unknown;
httpClient.get('/private').subscribe((response) => (privateResponse = response));
const privateRequest = httpTestingController.expectOne('/private');
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
privateRequest.flush({internalSecret: 'server-only'});
expect(privateResponse).toEqual({internalSecret: 'server-only'});
expect(JSON.parse(transferState.toJson())).toEqual({});
} finally {
grandchildInjector.destroy();
}
});
});
describe('withHttpTransferCache', () => {
let isStable: BehaviorSubject<boolean>;
@@ -78,7 +435,9 @@ describe('TransferCache', () => {
TestBed.inject(HttpClient)
.request(params?.method ?? 'GET', url, params)
.subscribe((r) => (response = r));
TestBed.inject(HttpTestingController).expectOne(url).flush(body, {headers: params?.headers});
TestBed.inject(HttpTestingController)
.expectOne(url)
.flush(body, {headers: params?.responseHeaders ?? params?.headers});
return response;
}
@@ -182,7 +541,7 @@ describe('TransferCache', () => {
const transferState = TestBed.inject(TransferState);
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
'2400571479': {
'2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': {
[BODY]: 'foo',
[HEADERS]: {},
[STATUS]: 200,
@@ -190,7 +549,7 @@ describe('TransferCache', () => {
[REQ_URL]: '/test-1',
[RESPONSE_TYPE]: 'json',
},
'2400572440': {
'869485290d9385f3c0a9ba571918c335bbca9e03373bf8260d02f2b7dd335849': {
[BODY]: 'buzz',
[HEADERS]: {},
[STATUS]: 200,
@@ -228,6 +587,38 @@ describe('TransferCache', () => {
);
});
it('should differentiate repeated parameters from scalar comma parameters', () => {
let scalarResponse!: string;
TestBed.inject(HttpClient)
.get('/test-params', {params: new HttpParams().set('role', 'user,admin')})
.subscribe((response) => (scalarResponse = response as string));
TestBed.inject(HttpTestingController)
.expectOne('/test-params?role=user,admin')
.flush('scalar');
let repeatedResponse!: string;
TestBed.inject(HttpClient)
.get('/test-params', {
params: new HttpParams().append('role', 'user').append('role', 'admin'),
})
.subscribe((response) => (repeatedResponse = response as string));
TestBed.inject(HttpTestingController)
.expectOne('/test-params?role=user&role=admin')
.flush('repeated');
let repeatedCachedResponse!: string;
TestBed.inject(HttpClient)
.get('/test-params', {
params: new HttpParams().append('role', 'user').append('role', 'admin'),
})
.subscribe((response) => (repeatedCachedResponse = response as string));
TestBed.inject(HttpTestingController).expectNone('/test-params?role=user&role=admin');
expect(scalarResponse).toBe('scalar');
expect(repeatedResponse).toBe('repeated');
expect(repeatedCachedResponse).toBe('repeated');
});
it('should skip cache when specified', () => {
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {transferCache: false});
// The previous request wasn't cached so this one can't use the cache
@@ -331,6 +722,164 @@ describe('TransferCache', () => {
makeRequestAndExpectOne('/test-auth', 'foo');
});
it('should not cache requests with credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
withCredentials: true,
});
makeRequestAndExpectOne('/test-auth', 'foo', {
withCredentials: true,
});
});
it('should not cache requests with included credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
});
it('should not cache requests with same-origin credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'same-origin',
});
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'same-origin',
});
});
it('should cache requests with omitted credentials', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'omit',
});
makeRequestAndExpectNone('/test-auth', 'GET', {
credentials: 'omit',
});
});
it('should not cache responses with Cache-Control: no-store', () => {
makeRequestAndExpectOne('/test-no-store', 'private-data', {
responseHeaders: {'Cache-Control': 'no-store'},
});
makeRequestAndExpectOne('/test-no-store', 'fresh-data');
});
it('should not cache responses with Cache-Control: private', () => {
makeRequestAndExpectOne('/test-private', 'user-data', {
responseHeaders: {'Cache-Control': 'private'},
});
makeRequestAndExpectOne('/test-private', 'fresh-data');
});
it('should not cache responses with a Set-Cookie header', () => {
makeRequestAndExpectOne('/test-set-cookie', 'user-a-session', {
responseHeaders: {'Set-Cookie': 'session=user-a; HttpOnly'},
});
makeRequestAndExpectOne('/test-set-cookie', 'user-b-session');
});
it('should not cache responses with Cache-Control containing no-store among other directives', () => {
makeRequestAndExpectOne('/test-multi', 'data', {
responseHeaders: {'Cache-Control': 'max-age=0, no-store, must-revalidate'},
});
makeRequestAndExpectOne('/test-multi', 'fresh-data');
});
it('should not cache responses with Cache-Control containing private among other directives', () => {
makeRequestAndExpectOne('/test-multi-private', 'data', {
responseHeaders: {'Cache-Control': 'max-age=60, private'},
});
makeRequestAndExpectOne('/test-multi-private', 'fresh-data');
});
it('should cache responses with Cache-Control: public', () => {
makeRequestAndExpectOne('/test-public', 'public-data', {
responseHeaders: {'Cache-Control': 'public'},
});
makeRequestAndExpectNone('/test-public');
});
it('should cache responses with Cache-Control: max-age without no-store or private', () => {
makeRequestAndExpectOne('/test-max-age', 'cacheable-data', {
responseHeaders: {'Cache-Control': 'max-age=3600'},
});
makeRequestAndExpectNone('/test-max-age');
});
it('should cache responses without Cache-Control header', () => {
makeRequestAndExpectOne('/test-no-cc', 'data');
makeRequestAndExpectNone('/test-no-cc');
});
it('should not cache responses with Cache-Control: no-store (case-insensitive)', () => {
makeRequestAndExpectOne('/test-case-resp', 'data', {
responseHeaders: {'Cache-Control': 'No-Store'},
});
makeRequestAndExpectOne('/test-case-resp', 'fresh-data');
});
it('should not cache requests with Cache-Control: no-store', () => {
makeRequestAndExpectOne('/test-req-no-store', 'data', {
headers: {'Cache-Control': 'no-store'},
});
makeRequestAndExpectOne('/test-req-no-store', 'fresh-data');
});
it('should not cache requests with Cache-Control: no-cache', () => {
makeRequestAndExpectOne('/test-req-no-cache', 'data', {
headers: {'Cache-Control': 'no-cache'},
});
makeRequestAndExpectOne('/test-req-no-cache', 'fresh-data');
});
it('should not cache requests with Cache-Control containing no-store among other directives', () => {
makeRequestAndExpectOne('/test-req-multi', 'data', {
headers: {'Cache-Control': 'max-age=0, no-store'},
});
makeRequestAndExpectOne('/test-req-multi', 'fresh-data');
});
it('should cache requests with Cache-Control: max-age', () => {
makeRequestAndExpectOne('/test-req-max-age', 'data', {
headers: {'Cache-Control': 'max-age=3600'},
});
makeRequestAndExpectNone('/test-req-max-age');
});
it('should not cache requests with Fetch API cache mode: no-store', () => {
makeRequestAndExpectOne('/test-fetch-no-store', 'data', {
cache: 'no-store',
});
makeRequestAndExpectOne('/test-fetch-no-store', 'fresh-data');
});
it('should not cache requests with Fetch API cache mode: no-cache', () => {
makeRequestAndExpectOne('/test-fetch-no-cache', 'data', {
cache: 'no-cache',
});
makeRequestAndExpectOne('/test-fetch-no-cache', 'fresh-data');
});
it('should cache POST with the differing body in string form', () => {
makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'});
makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'});
@@ -483,6 +1032,26 @@ describe('TransferCache', () => {
makeRequestAndExpectNone('/test-auth');
});
it(`should not cache requests with credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
withCredentials: true,
});
makeRequestAndExpectOne('/test-auth', 'foo', {
withCredentials: true,
});
});
it(`should not cache requests with included credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
makeRequestAndExpectOne('/test-auth', 'foo', {
credentials: 'include',
});
});
it('should cache a POST request', () => {
makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'});
@@ -692,4 +1261,34 @@ describe('TransferCache', () => {
});
});
});
describe('generateHash', () => {
async function computeNativeSha256(value: string): Promise<string> {
const msgUint8 = new TextEncoder().encode(value);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
it('should generate standard SHA-256 hashes matching Web Crypto specs', async () => {
const testCases = [
'',
'hello',
'angular',
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
'Angular 🚀',
'a'.repeat(55),
'a'.repeat(56),
'a'.repeat(63),
'a'.repeat(64),
'a'.repeat(65),
'a'.repeat(1000),
];
for (const testCase of testCases) {
const expected = await computeNativeSha256(testCase);
expect(generateHash(testCase)).withContext(`For: ${testCase}`).toBe(expected);
}
});
});
});
@@ -727,13 +727,15 @@ export class NgOptimizedImage implements OnInit, OnChanges {
protected generatePlaceholder(placeholderInput: string | boolean): string | boolean | null {
const {placeholderResolution} = this.config;
if (placeholderInput === true) {
return `url(${this.callImageLoader({
src: this.ngSrc,
width: placeholderResolution,
isPlaceholder: true,
})})`;
return `url("${escapeCssUrl(
this.callImageLoader({
src: this.ngSrc,
width: placeholderResolution,
isPlaceholder: true,
}),
)}")`;
} else if (typeof placeholderInput === 'string') {
return `url(${placeholderInput})`;
return `url("${escapeCssUrl(placeholderInput)}")`;
}
return null;
}
@@ -1461,6 +1463,10 @@ function unwrapSafeUrl(value: string | SafeValue): string {
return unwrapSafeValue(value);
}
function escapeCssUrl(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// Transform function to handle inputs which may be booleans, strings, or string representations
// of boolean values. Used for the placeholder attribute.
export function booleanOrUrlAttribute(value: boolean | string): boolean | string {
+12
View File
@@ -36,6 +36,7 @@ export const ISO8601_DATE_REGEX =
const NAMED_FORMATS: {[localeId: string]: {[format: string]: string}} = {};
const DATE_FORMATS_SPLIT =
/((?:[^BEGHLMOSWYZabcdhmswyz']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|Y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|c{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/;
const MAX_DATE_FORMAT_LENGTH = 256;
const enum ZoneWidth {
Short,
@@ -89,6 +90,7 @@ export function formatDate(
timezone?: string,
): string {
let date = toDate(value);
assertValidDateFormatLength(format);
const namedFormat = getNamedFormat(locale, format);
format = namedFormat || format;
@@ -132,6 +134,16 @@ export function formatDate(
return text;
}
function assertValidDateFormatLength(format: string) {
if (format.length > MAX_DATE_FORMAT_LENGTH) {
throw new RuntimeError(
RuntimeErrorCode.SUSPICIOUS_DATE_FORMAT,
ngDevMode &&
`Date format is too long. Exceeded maximum length of ${MAX_DATE_FORMAT_LENGTH} characters.`,
);
}
}
/**
* Asserts that the given date format is free from common mistakes. Throws an
* error if one is found (except for the case of all "Y", in which case we just
+15 -1
View File
@@ -8,6 +8,7 @@
import {ɵRuntimeError as RuntimeError} from '@angular/core';
import {RuntimeErrorCode} from '../errors';
import {
getLocaleNumberFormat,
getLocaleNumberSymbol,
@@ -15,7 +16,6 @@ import {
NumberFormatStyle,
NumberSymbol,
} from './locale_data_api';
import {RuntimeErrorCode} from '../errors';
export const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/;
const MAX_DIGITS = 22;
@@ -77,6 +77,20 @@ function formatNumberToLocaleString(
} else if (minFractionPart != null && minFraction > maxFraction) {
maxFraction = minFraction;
}
// Prevent DoS via resource exhaustion by capping the maximum padding iterations
const MAX_ALLOWED_DIGITS = 100;
if (
minInt > MAX_ALLOWED_DIGITS ||
minFraction > MAX_ALLOWED_DIGITS ||
maxFraction > MAX_ALLOWED_DIGITS
) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_DIGIT_INFO,
ngDevMode &&
`${digitsInfo} is not a valid digit info. Exceeded maximum limits of ${MAX_ALLOWED_DIGITS} digits.`,
);
}
}
roundNumber(parsedNumber, minFraction, maxFraction);
+1 -1
View File
@@ -319,7 +319,7 @@ function _stripBasePath(basePath: string, url: string): string {
}
function _stripIndexHtml(url: string): string {
return url.replace(/\/index.html$/, '');
return url.replace(/\/index\.html$/, '');
}
function _stripOrigin(baseHref: string): string {
@@ -1215,6 +1215,25 @@ describe('Image directive', () => {
);
});
it('should escape loader output when placeholder is provided as a boolean (security regression)', () => {
const maliciousLoader = (config: ImageLoaderConfig) => {
if (config.isPlaceholder) {
return 'https://mysite.com/img.png"); color: red;/*';
}
return `${IMG_BASE_URL}/${config.src}`;
};
setupTestingModule({imageLoader: maliciousLoader});
const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="true" />';
const fixture = createTestComponent(template);
fixture.detectChanges();
const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage);
const placeholderValue = (imageDirective as any).generatePlaceholder(true) as string;
expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;/*")');
});
if (!isNode) {
// DataURLs get stripped from background-image attribute in Node, but not browsers.
it('should add a background-image tag when placeholder is provided as a data URL', () => {
@@ -1264,6 +1283,22 @@ describe('Image directive', () => {
);
});
it('should prevent CSS injection through placeholder URL values', () => {
setupTestingModule({imageLoader});
const maliciousPlaceholder = 'https://mysite.com/img.png"); color: red;';
const template = `<img ngSrc="path/img.png" width="400" height="300" placeholder='${maliciousPlaceholder}' />`;
const fixture = createTestComponent(template);
fixture.detectChanges();
const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage);
const placeholderValue = (imageDirective as any).generatePlaceholder(
maliciousPlaceholder,
) as string;
expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;")');
});
it('should add a background-image tag when placeholder is provided without value', () => {
setupTestingModule({imageLoader});
const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder />';
@@ -351,6 +351,16 @@ describe('Format date', () => {
});
});
it('should throw if the date format exceeds 256 characters to prevent DoS', () => {
const expectedError = /Exceeded maximum length of 256 characters/;
expect(() => formatDate(date, 'y'.repeat(257), ɵDEFAULT_LOCALE_ID)).toThrowError(
expectedError,
);
expect(() => formatDate(date, "'literal'".repeat(257), ɵDEFAULT_LOCALE_ID)).toThrowError(
expectedError,
);
});
it('should format invalid in IE ISO date', () =>
expect(formatDate('2017-01-11T12:00:00.014-0500', defaultFormat, ɵDEFAULT_LOCALE_ID)).toEqual(
'Jan 11, 2017',
@@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ɵDEFAULT_LOCALE_ID, ɵregisterLocaleData, ɵunregisterLocaleData} from '@angular/core';
import {formatCurrency, formatNumber, formatPercent} from '../../index';
import localeAr from '../../locales/ar';
import localeEn from '../../locales/en';
import localeEsUS from '../../locales/es-US';
import localeFr from '../../locales/fr';
import {ɵDEFAULT_LOCALE_ID, ɵregisterLocaleData, ɵunregisterLocaleData} from '@angular/core';
describe('Format number', () => {
beforeAll(() => {
@@ -43,6 +43,18 @@ describe('Format number', () => {
/is higher than the maximum/,
);
});
it('should throw if minInt, minFraction, or maxFraction exceeds 100 to prevent DoS', () => {
const expectedError = /Exceeded maximum limits of 100 digits/;
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '101.4-5')).toThrowError(expectedError);
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '3.101-105')).toThrowError(
expectedError,
);
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '3.4-101')).toThrowError(expectedError);
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '1.2000000000-20000000')).toThrowError(
expectedError,
);
});
});
describe('transform with custom locales', () => {
@@ -304,5 +304,19 @@ describe('Location Class', () => {
expect(location.normalize(baseHref)).toBe('');
expect(location.normalize(baseHref + path)).toBe(path);
});
it('should only strip a literal /index.html suffix, not arbitrary characters', () => {
const baseHref = '/en';
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
const location = TestBed.inject(Location);
// The `.` in the strip regex must be escaped; otherwise it would match any
// character and turn e.g. `/foo/indexXhtml` into `/foo`.
expect(location.normalize('/en/page/indexXhtml')).toBe('/page/indexXhtml');
expect(location.normalize('/en/page/index_html')).toBe('/page/index_html');
expect(location.normalize('/en/page/index.html')).toBe('/page');
});
});
});
@@ -77,6 +77,12 @@ describe('DatePipe', () => {
it('should give precedence to the passed in format', () =>
expect(pipe.transform('2017-01-11T10:14:39+0000', 'shortDate')).toEqual('1/11/17'));
it('should reject date formats that are too long', () => {
expect(() => pipe.transform(date, 'y'.repeat(257))).toThrowError(
/InvalidPipeArgument: 'NG02300: Date format is too long/,
);
});
it('should use format provided in component as default format when no format is passed in', () => {
@Component({
selector: 'test-component',
+1 -1
View File
@@ -30,7 +30,7 @@
}
},
"dependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.29.7",
"@jridgewell/sourcemap-codec": "^1.4.14",
"reflect-metadata": "^0.2.0",
"chokidar": "^5.0.0",
@@ -168,7 +168,7 @@ function extractParams(
}
/**
* Find the the interface usually suffixed with "Decorator" that describes the decorator.
* Find the interface usually suffixed with "Decorator" that describes the decorator.
*/
function getDecoratorInterface(declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker) {
const name = declaration.name.getText();
@@ -133,17 +133,21 @@ function filterSignatureDeclarations(signatures: readonly ts.Signature[]) {
}
export function extractCallSignatures(name: string, typeChecker: ts.TypeChecker, type: ts.Type) {
return filterSignatureDeclarations(type.getCallSignatures()).map(({decl, signature}) => ({
name,
entryType: EntryType.Function,
description: extractJsDocDescription(decl),
generics: extractGenerics(decl),
isNewType: false,
jsdocTags: extractJsDocTags(decl),
params: extractAllParams(decl.parameters, typeChecker),
rawComment: extractRawJsDoc(decl),
returnType: extractReturnType(signature, typeChecker),
}));
return filterSignatureDeclarations(type.getCallSignatures()).map(({decl, signature}) => {
const jsdocTags = extractJsDocTags(decl);
return {
name,
entryType: EntryType.Function,
description: extractJsDocDescription(decl),
generics: extractGenerics(decl),
isNewType: false,
jsdocTags,
params: extractAllParams(decl.parameters, typeChecker),
rawComment: extractRawJsDoc(decl),
returnType: extractReturnType(signature, typeChecker),
returnDescription: jsdocTags.find((tag) => tag.name === 'returns')?.comment,
};
});
}
function extractReturnType(signature: ts.Signature, typeChecker: ts.TypeChecker): string {
@@ -185,8 +185,6 @@ export interface IndexedComponent<T = DeclarationNode> {
file: ParseSourceFile;
template: {
identifiers: Set<TopLevelIdentifier<T>>;
usedComponents: Set<T>;
isInline: boolean;
file: ParseSourceFile;
};
errors: Error[];
@@ -30,14 +30,6 @@ export function generateAnalysis<T = DeclarationNode>(
const name = adapter.getName(declaration);
const fileName = adapter.getFileName(declaration);
const usedComponents = new Set<T>();
const usedDirs = boundTemplate.getUsedDirectives();
usedDirs.forEach((dir) => {
if (dir.isComponent) {
usedComponents.add(dir.ref.node);
}
});
// Get source files for the component and the template. If the template is inline, its source
// file is the component's.
const componentFile = new ParseSourceFile(adapter.getContent(declaration), fileName);
@@ -55,8 +47,6 @@ export function generateAnalysis<T = DeclarationNode>(
file: componentFile,
template: {
identifiers,
usedComponents,
isInline: templateMeta.isInline,
file: templateFile,
},
errors,
@@ -70,8 +70,6 @@ runInEachFileSystem(() => {
template: {
identifiers: getTemplateIdentifiers(util.getBoundTemplate('<div>{{foo}}</div>'))
.identifiers,
usedComponents: new Set(),
isInline: false,
file: new ParseSourceFile('<div>{{foo}}</div>', decl.getSourceFile().fileName),
},
errors: [],
@@ -116,37 +114,5 @@ runInEachFileSystem(() => {
new ParseSourceFile('<div>{{foo}}</div>', decl.getSourceFile().fileName),
);
});
it('should emit used components', () => {
const context = new IndexingContext();
const templateA = '<b-selector></b-selector>';
const declA = util.getComponentDeclaration('class A {}', 'A');
const templateB = '<a-selector></a-selector>';
const declB = util.getComponentDeclaration('class B {}', 'B');
const boundA = util.getBoundTemplate(templateA, {}, [
{selector: 'b-selector', declaration: declB},
]);
const boundB = util.getBoundTemplate(templateB, {}, [
{selector: 'a-selector', declaration: declA},
]);
populateContext(context, declA, 'a-selector', templateA, boundA);
populateContext(context, declB, 'b-selector', templateB, boundB);
const analysis = generateAnalysis(context, adapter);
expect(analysis.size).toBe(2);
const infoA = analysis.get(declA);
expect(infoA).toBeDefined();
expect(infoA!.template.usedComponents).toEqual(new Set([declB]));
const infoB = analysis.get(declB);
expect(infoB).toBeDefined();
expect(infoB!.template.usedComponents).toEqual(new Set([declA]));
});
});
});
@@ -1,5 +1,6 @@
const $_c0$ = [[["basic"]], "*", [["footer"]], [["structural"]]];
const $_c1$ = ["basic", "*", "footer", "structural"];
const $_c2$ = ["*ngIf", "hasStructural"];
function TestComponent_ProjectionFallback_0_Template(rf, ctx) {
if (rf & 1) {
@@ -46,7 +47,7 @@ function TestComponent_ng_content_6_ProjectionFallback_0_Template(rf, ctx) {
function TestComponent_ng_content_6_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵprojection(0, 3, ["*ngIf", "hasStructural"], TestComponent_ng_content_6_ProjectionFallback_0_Template, 2, 0);
$r3$.ɵɵprojection(0, 3, $_c2$, TestComponent_ng_content_6_ProjectionFallback_0_Template, 2, 0);
}
}
@@ -1,16 +1,17 @@
const _c0 = [[["", "card-title", ""]], [["", "card-content", ""]]];
const _c1 = ["[card-title]", "[card-content]"];
const _c2 = ["*"];
const $_c0$ = [[["", "card-title", ""]], [["", "card-content", ""]]];
const $_c1$ = ["[card-title]", "[card-content]"];
const $_c2$ = ["*"];
const $_c3$ = ["ngProjectAs", "[card-content]", 5, ["", "card-content", ""]];
// ...
selectors: [["card"]],
standalone: false,
ngContentSelectors: _c1,
ngContentSelectors: $_c1$,
decls: 3,
vars: 0,
template: function Card_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵprojectionDef(_c0);
i0.ɵɵprojectionDef($_c0$);
i0.ɵɵprojection(0);
i0.ɵɵtext(1, " --- ");
i0.ɵɵprojection(2, 1);
@@ -19,7 +20,7 @@ template: function Card_Template(rf, ctx) {
// ...
selectors: [["card-with-title"]],
standalone: false,
ngContentSelectors: _c2,
ngContentSelectors: $_c2$,
decls: 4,
vars: 0,
consts: [["ngProjectAs", "[card-title]", 5, ["", "card-title", ""]]],
@@ -29,7 +30,7 @@ template: function CardWithTitle_Template(rf, ctx) {
i0.ɵɵelementStart(0, "card")(1, "h1", 0);
i0.ɵɵtext(2, "Title");
i0.ɵɵelementEnd();
i0.ɵɵprojection(3, 0, ["ngProjectAs", "[card-content]", 5, ["", "card-content", ""]]);
i0.ɵɵprojection(3, 0, $_c3$);
i0.ɵɵelementEnd();
}
}
@@ -44,4 +45,4 @@ template: function App_Template(rf, ctx) {
i0.ɵɵtext(1, "content");
i0.ɵɵelementEnd();
}
}
}
@@ -936,6 +936,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
},
}]
}] });
export class HostBindingCustomSrcdocDir {
evil = 'evil';
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcdocDir, isStandalone: true, selector: "safe-srcdoc-carrier", host: { properties: { "attr.srcdoc": "evil" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, decorators: [{
type: Directive,
args: [{
selector: 'safe-srcdoc-carrier',
host: {
'[attr.srcdoc]': 'evil',
},
}]
}] });
export class HostBindingCustomSrcDir {
evil = 'evil';
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcDir, isStandalone: true, selector: "safe-src-carrier", host: { properties: { "attr.src": "evil" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, decorators: [{
type: Directive,
args: [{
selector: 'safe-src-carrier',
host: {
'[attr.src]': 'evil',
},
}]
}] });
export class HostBindingCustomDataDir {
evil = 'evil';
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomDataDir, isStandalone: true, selector: "safe-data-carrier", host: { properties: { "attr.data": "evil" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, decorators: [{
type: Directive,
args: [{
selector: 'safe-data-carrier',
host: {
'[attr.data]': 'evil',
},
}]
}] });
/****************************************************************************************************
* PARTIAL FILE: sanitization.d.ts
@@ -963,6 +1005,21 @@ export declare class HostBindingSvgAnimateDir {
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingSvgAnimateDir, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingSvgAnimateDir, "animateMotion[hostBindingSvgAnimateDir]", never, {}, {}, never, never, true, never>;
}
export declare class HostBindingCustomSrcdocDir {
evil: string;
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomSrcdocDir, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomSrcdocDir, "safe-srcdoc-carrier", never, {}, {}, never, never, true, never>;
}
export declare class HostBindingCustomSrcDir {
evil: string;
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomSrcDir, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomSrcDir, "safe-src-carrier", never, {}, {}, never, never, true, never>;
}
export declare class HostBindingCustomDataDir {
evil: string;
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomDataDir, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomDataDir, "safe-data-carrier", never, {}, {}, never, never, true, never>;
}
/****************************************************************************************************
* PARTIAL FILE: security_sensitive_constant_attributes.js
@@ -1,21 +1,21 @@
hostBindings: function HostBindingLinkDir_HostBindings(rf, ctx) {
if (rf & 2) {
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrl);
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrlOrResourceUrl);
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle);
}
}
…
hostBindings: function HostBindingImageDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrl);
i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrlOrResourceUrl);
i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle);
}
}
…
hostBindings: function HostBindingIframeDir_HostBindings(rf, ctx) {
if (rf & 2) {
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil);
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil, i0.ɵɵvalidateAttribute);
}
}
…
@@ -24,3 +24,21 @@ hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) {
i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute);
}
}
…
hostBindings: function HostBindingCustomSrcdocDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("srcdoc", ctx.evil, i0.ɵɵsanitizeHtml);
}
}
…
hostBindings: function HostBindingCustomSrcDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl);
}
}
…
hostBindings: function HostBindingCustomDataDir_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("data", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl);
}
}
@@ -49,3 +49,33 @@ export class HostBindingIframeDir {
export class HostBindingSvgAnimateDir {
evil = 'evil';
}
@Directive({
selector: 'safe-srcdoc-carrier',
host: {
'[attr.srcdoc]': 'evil',
},
})
export class HostBindingCustomSrcdocDir {
evil = 'evil';
}
@Directive({
selector: 'safe-src-carrier',
host: {
'[attr.src]': 'evil',
},
})
export class HostBindingCustomSrcDir {
evil = 'evil';
}
@Directive({
selector: 'safe-data-carrier',
host: {
'[attr.data]': 'evil',
},
})
export class HostBindingCustomDataDir {
evil = 'evil';
}
@@ -828,6 +828,15 @@ export class MyComponent {
<iframe [sandbox]="evil"></iframe>
<a href="{{evil}}{{evil}}"></a>
<div attr.style="{{evil}}{{evil}}"></div>
<div [(innerHTML)]="evil"></div>
<div bindon-innerHTML="evil"></div>
<iframe [(srcdoc)]="evil"></iframe>
<iframe bindon-srcdoc="evil"></iframe>
<img [(src)]="evil" />
<iframe [(src)]="evil"></iframe>
<object [(data)]="evil"></object>
<link [(href)]="evil" />
<iframe [(sandbox)]="evil"></iframe>
`, isInline: true });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
@@ -842,6 +851,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
<iframe [sandbox]="evil"></iframe>
<a href="{{evil}}{{evil}}"></a>
<div attr.style="{{evil}}{{evil}}"></div>
<div [(innerHTML)]="evil"></div>
<div bindon-innerHTML="evil"></div>
<iframe [(srcdoc)]="evil"></iframe>
<iframe bindon-srcdoc="evil"></iframe>
<img [(src)]="evil" />
<iframe [(src)]="evil"></iframe>
<object [(data)]="evil"></object>
<link [(href)]="evil" />
<iframe [(sandbox)]="evil"></iframe>
`
}]
}] });
@@ -1,7 +1,5 @@
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵdomElement(0, "div", 0)(1, "link", 1)(2, "div")(3, "img", 2)(4, "iframe", 3)(5, "a", 1)(6, "div");
}
…
if (rf & 2) {
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
$r3$.ɵɵadvance();
@@ -16,5 +14,23 @@ template: function MyComponent_Template(rf, ctx) {
$r3$.ɵɵdomProperty("href", $r3$.ɵɵinterpolate2("", ctx.evil, "", ctx.evil), $r3$.ɵɵsanitizeUrl);
$r3$.ɵɵadvance();
$r3$.ɵɵattribute("style", $r3$.ɵɵinterpolate2("", ctx.evil, "", ctx.evil), $r3$.ɵɵsanitizeStyle);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("srcdoc", ctx.evil, $r3$.ɵɵsanitizeHtml);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("srcdoc", ctx.evil, $r3$.ɵɵsanitizeHtml);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("src", ctx.evil, $r3$.ɵɵsanitizeUrl);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("src", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("data", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("href", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
$r3$.ɵɵadvance();
$r3$.ɵɵtwoWayProperty("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
}
}
@@ -10,6 +10,15 @@ import {Component} from '@angular/core';
<iframe [sandbox]="evil"></iframe>
<a href="{{evil}}{{evil}}"></a>
<div attr.style="{{evil}}{{evil}}"></div>
<div [(innerHTML)]="evil"></div>
<div bindon-innerHTML="evil"></div>
<iframe [(srcdoc)]="evil"></iframe>
<iframe bindon-srcdoc="evil"></iframe>
<img [(src)]="evil" />
<iframe [(src)]="evil"></iframe>
<object [(data)]="evil"></object>
<link [(href)]="evil" />
<iframe [(sandbox)]="evil"></iframe>
`
})
export class MyComponent {
@@ -1,3 +1,5 @@
const $_c1$ = ["*ngIf", "flag"];
function MyComponent_img_2_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵi18nStart(0, 0, 1);
@@ -34,7 +36,7 @@ function MyComponent_ng_container_5_Template(rf, ctx) {
function MyComponent_ng_content_6_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵi18nStart(0, 0, 6);
i0.ɵɵprojection(1, 0, ["*ngIf", "flag"]);
i0.ɵɵprojection(1, 0, $_c1$);
i0.ɵɵi18nEnd();
}
}
@@ -83,8 +83,6 @@ runInEachFileSystem(() => {
target: null,
},
]),
usedComponents: new Set(),
isInline: true,
file: new ParseSourceFile(componentContent, testSourceFile),
});
});
@@ -116,8 +114,6 @@ runInEachFileSystem(() => {
target: null,
},
]),
usedComponents: new Set(),
isInline: false,
file: new ParseSourceFile('{{foo}}', testTemplateFile),
});
});
@@ -153,8 +149,6 @@ runInEachFileSystem(() => {
target: null,
},
]),
usedComponents: new Set(),
isInline: false,
file: new ParseSourceFile(' \n {{foo}}', testTemplateFile),
});
});
@@ -205,12 +199,6 @@ runInEachFileSystem(() => {
const testImportComp = indexedComps.find((cmp) => cmp.name === 'TestImportCmp');
expect(testComp).toBeDefined();
expect(testImportComp).toBeDefined();
expect(testComp!.template.usedComponents.size).toBe(0);
expect(testImportComp!.template.usedComponents.size).toBe(1);
const [usedComp] = Array.from(testImportComp!.template.usedComponents);
expect(indexed.get(usedComp)).toEqual(testComp);
});
});
});
+37 -33
View File
@@ -8569,7 +8569,7 @@ runInEachFileSystem((os: string) => {
hostVars: 6,
hostBindings: function UnsafeAttrsDirective_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrlOrResourceUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
}
}
`;
@@ -8615,42 +8615,14 @@ runInEachFileSystem((os: string) => {
hostVars: 3,
hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
}
}
`;
expect(trim(jsContents)).toContain(trim(hostBindingsFn));
});
it('should generate sanitizers for URL properties in SVG script fn in Component', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
template: \`
<svg>
<script [attr.xlink:href]="attr" [attr.href]="attr"></script>
</svg>
\`,
})
export class TestCmp {
attr = './script.js';
}
`,
);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain(
'i0.ɵɵattribute("href", ctx.attr, i0.ɵɵsanitizeResourceUrl, "xlink")("href", ctx.attr, i0.ɵɵsanitizeResourceUrl);',
);
});
it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => {
it('should generate concrete-host URL sanitizers in hostBindings fn in Component', () => {
env.write(
`test.ts`,
`
@@ -8683,8 +8655,40 @@ runInEachFileSystem((os: string) => {
hostVars: 5,
hostBindings: function FooCmp_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrl)("title", ctx.titleProp);
i0.ɵɵattribute("src", ctx.srcAttr)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrl)("title", ctx.titleAttr);
i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleProp);
i0.ɵɵattribute("src", ctx.srcAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleAttr);
}
}
`;
expect(trim(jsContents)).toContain(trim(hostBindingsFn));
});
it('should generate sanitizers for pure :not selector host bindings', () => {
env.write(
`test.ts`,
`
import {Component} from '@angular/core';
@Component({
selector: ':not(iframe)',
template: '',
host: {
'[attr.srcdoc]': 'srcdoc',
}
})
class FooCmp {
srcdoc: any;
}
`,
);
env.driveMain();
const jsContents = env.getContents('test.js');
const hostBindingsFn = `
hostVars: 1,
hostBindings: function FooCmp_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("srcdoc", ctx.srcdoc, i0.ɵɵsanitizeHtml);
}
}
`;
-1
View File
@@ -32,7 +32,6 @@ import {publishFacade} from './jit_compiler_facade';
import * as outputAst from './output/output_ast';
import {global} from './util';
export {SECURITY_SCHEMA} from './schema/dom_security_schema';
export {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from './core';
export {core};
+2 -10
View File
@@ -79,16 +79,6 @@ export interface Type extends Function {
}
export const Type = Function;
export enum SecurityContext {
NONE = 0,
HTML = 1,
STYLE = 2,
SCRIPT = 3,
URL = 4,
RESOURCE_URL = 5,
ATTRIBUTE_NO_BINDING = 6,
}
/**
* Injection flags for DI.
*/
@@ -331,3 +321,5 @@ export const enum AttributeMarker {
*/
I18n = 6,
}
export {SecurityContext} from './schema/dom_security_schema';
+1 -1
View File
@@ -160,7 +160,7 @@ class _I18nVisitor implements html.Visitor {
}
// Else returns a placeholder
// ICU placeholders should not be replaced with their original content but with the their
// ICU placeholders should not be replaced with their original content but with their
// translations.
// TODO(vicb): add a html.Node -> i18n.Message cache to avoid having to re-create the msg
const phName = context.placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString());
@@ -208,7 +208,7 @@ export class I18nMetaVisitor implements html.Visitor {
isTrustedType = isTrustedTypesSink(node.name, name);
}
if (isTrustedType) {
if (isTrustedType || isPossibleEventHandler(name)) {
this._reportError(
attr,
`Translating attribute '${name}' is disallowed for security reasons.`,
@@ -350,3 +350,14 @@ export function i18nMetaToJSDoc(meta: I18nMeta): o.JSDocComment {
}
return o.jsDocComment(tags);
}
/**
* Check if the propertyName is a potential event handler.
* We consider a property to be a potential event handler if its name is longer than 2 characters and starts with 'on' (e.g. 'onclick', 'onload', etc.).
* @param propertyName The name of the property to check.
* @returns True if the property is a potential event handler, false otherwise.
*/
function isPossibleEventHandler(propertyName: string): boolean {
const name = propertyName.toLowerCase();
return name.length > 2 && name !== 'only' && name.startsWith('on');
}

Some files were not shown because too many files have changed in this diff Show More