Compare commits

...

45 Commits

Author SHA1 Message Date
Andrew Scott c22c5c4543 release: cut the v17.3.0 release 2024-03-13 14:05:31 -07:00
JoostK 5d41ab9cd1 refactor(core): restructure logic in isCssClassMatching function (#54800)
The logic in `isCssClassMatching` is only interested in two areas in the attributes:
implicit attributes and the `AttributeMarker.Classes` area, with the first area only
of interest for projection matching, not directive matching. This commit splits these
two searches to make this more apparent.

PR Close #54800
2024-03-12 14:05:19 -07:00
JoostK 243ccce624 fix(core): exclude class attribute intended for projection matching from directive matching (#54800)
This commit resolves a regression that was introduced when the compiler switched from
`TemplateDefinitionBuilder` (TDB) to the template pipeline (TP) compiler. The TP compiler
has changed the output of

```html
if (false) { <div class="test"></div> }
```

from

```ts
defineComponent({
  consts: [['class', 'test'], [AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

to

```ts
defineComponent({
  consts: [[AttributeMarker.Classes, 'test']],
  template: function(rf) {
    if (rf & 1) {
      ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0)
    }
  }
});
```

The last argument to the `ɵɵtemplate` instruction (0 in both compilation outputs) corresponds with
the index in `consts` of the element's attribute's, and we observe how TP has allocated only a single
attribute array for the `div`, where there used to be two `consts` entries with TDB. Consequently,
the `ɵɵtemplate` instruction is now effectively referencing a different attributes array, where the
distinction between the `"class"` attribute vs. the `AttributeMarker.Classes` distinction affects
the behavior: TP's emit causes the runtime to incorrectly match a directive with `selector: '.foo'` to
be instantiated on the `ɵɵtemplate` instruction as if it corresponds with a structural directive!

Instead of changing TP to align with TDB's emit, this commit updates the runtime instead. This uncovered
an inconsistency in selector matching for class names, where there used to be two paths dealing with
class matching:

1. The first check was commented to be a special-case for class matching, implemented in `isCssClassMatching`.
2. The second path was part of the main selector matching algorithm, where `findAttrIndexInNode` was being used
   to find the start position in `tNode.attrs` to match the selector's value against.

The second path only considers `AttributeMarker.Classes` values if matching for content projection, OR of the
`TNode` is not an inline template. The special-case in path 1 however does not make that distinction, so it
would consider the `AttributeMarker.Classes` binding as a selector match, incorrectly causing a directive to
match on the `ɵɵtemplate` itself.

The second path was also buggy for class bindings, as the return value of `classIndexOf` was incorrectly
negated: it considered a matching class attribute as non-matching and vice-versa. This bug was not observable
because of another issue, where the class-handling in part 2 was never relevant because of the special-case
in part 1.

This commit separates path 1 entirely from path 2 and removes the buggy class-matching logic in part 2, as
that is entirely handled by path 1 anyway. `isCssClassMatching` is updated to exclude class bindings from
being matched for inline templates.

Fixes #54798

PR Close #54800
2024-03-12 14:05:18 -07:00
Ben Hong e8badecf4f docs: fix missing security guide in navigation (#54830)
The paths for the security guide were flipped in the original PR. As a result, it looked for a markdown file in the best-practices directory when it should have looked for it in the guide directory instead.

PR Close #54830
2024-03-12 13:13:03 -07:00
Gerald Monaco cdfcb779df refactor(core): add internal API to enable i18n hydration (#54784)
Add an internal API to enable and use i18n hydration for testing and development. This helps ensure that we don't accidentally break the current behavior until we are completely ready to roll out i18n support.

PR Close #54784
2024-03-12 11:39:03 -07:00
cexbrayat ac395d0f68 refactor(compiler-cli): cleanup unused code (#54775)
The initializer api no longer needs to take care of `ɵoutput`.

PR Close #54775
2024-03-12 11:31:39 -07:00
Ben Hong e75a9a4d83 docs: improve in-depth guides information architecture (#54365)
PR Close #54365
2024-03-12 10:25:40 -07:00
Joey Perrott 41f28f88b1 fix(docs-infra): remove the prerender directory from adev (#54820)
Remove the prerender directory as it is entirely unused and unneeded

PR Close #54820
2024-03-12 10:24:08 -07:00
Paul Gschwendtner 7df0a8a278 refactor(core): report subscription errors for OutputEmitterRef to ErrorHandler (#54821)
Currently if an `(output)` listener fails, it will be handled gracefully
by Angular and reported to the `ErrorHandler`.

For programmatic subscriptions with `OutputEmitterRef`, this is not the case.
Instead, as soon as any subscription is failing, all other subsequent
subscription callbacks are not firing anymore.

This commit intends to make this more consistent by gracefully
reporting errors from `OutputEmitterRef#emit` to `ErrorHandler`,
allowing for listener execution to continue.

PR Close #54821
2024-03-12 10:21:50 -07:00
cexbrayat 9889346620 docs: typo in output documentation (#54773)
PR Close #54773
2024-03-12 09:15:05 -07:00
Kristiyan Kostadinov 39a50f9a8d fix(core): ensure all initializer functions run in an injection context (#54761)
Ensures that all of the functions intended to be run in initializers are in an injection context. This is a stop-gap until we have a compiler diagnostic for it.

PR Close #54761
2024-03-12 09:08:08 -07:00
Pawel Kozlowski 1f77083a2d refactor(core): assert presence of the track function (#54814)
This commits assert that the repeater instruction gets a reference
to a tracking function. This change will allow us to better track
occurences of https://github.com/angular/angular/issues/53628 -
in certain situations a reference to a tracking function might be
undefiened.

We are not fixing the underlying issue here, just getting better
visibility.

PR Close #54814
2024-03-11 16:33:01 -07:00
Paul Gschwendtner bb35414a38 perf(core): speed up retrieval of DestroyRef in EventEmitter (#54748)
Speeds up the retrieval of `DestroyRef` in `EventEmitter` because
`try/catch` is expensive if there is no injection context.

We saw a script time regression in Cloud.

The goldens had to be updated because `getInjectImplementation` is now
referenced. `inject` also references the underlying field, but directly.
This is super minimal overhead of a function exposing the internal
field.

PR Close #54748
2024-03-11 16:31:03 -07:00
Matthieu Riegler fcfb42a8ba docs: use the right path for first-app images (#54694)
PR Close #54694
2024-03-11 16:30:28 -07:00
Joey Perrott 57e54e29e7 docs: bump to 2024 for copyright text (#54822)
Bump copyright text to 2024

PR Close #54822
2024-03-11 13:39:49 -07:00
Andrew Kushnir 2909e9817d fix(core): prevent infinite loops in clobbered elements check (#54425)
This commit updates HTML sanitization logic to avoid infinite loops in case clobbered elements contain fields like `nextSibling` or `parentNode`. Those fields are used for DOM traversal and this update makes sure that those calls return valid results.

Also this commit fixes an issue when clobbering `nodeName` causes JS exceptions.

PR Close #54425
2024-03-11 12:46:26 -07:00
Gerald Monaco 619f3c8792 refactor(core): add i18nNodes in preparation for i18n hydration (#54750)
An i18n message effectively acts as a dynamic template: two elements with contiguous instruction indices won't necessarily be contiguous in the DOM.

For that reason, we need to maintain a mapping from instruction index to a physical DOM node in order to hydrate views with i18n, pointing to where hydration for that view should begin.

PR Close #54750
2024-03-11 11:12:46 -07:00
Andrew Scott 0ee1a1419d refactor(core): Remove isG3 usages in patch branch (#54816)
This commit removes uses of isG3 in the patch branch. G3 always executes
against main so any references in patch are unused.

PR Close #54816
2024-03-11 11:11:32 -07:00
Kristiyan Kostadinov f386a04c9d fix(compiler): handle two-way bindings to signal-based template variables in instruction generation (#54714)
Updates the instruction generation for two-way bindings to only emit the `twoWayBindingSet` call when writing to template variables. Since template variables are constants, it's only allowed to write to them when they're signals. Non-signal values are flagged during template type checking.

Fixes #54670.

PR Close #54714
2024-03-11 11:01:43 -07:00
Kristiyan Kostadinov 492e03f699 fix(compiler-cli): flag two-way bindings to non-signal values in templates (#54714)
We have a diagnostic that reports writes to template variables which worked both for regular event bindings and two-way bindings, however the latter was broken by #54154 because two-way bindings no longer had a `PropertyWrite` AST.

These changes fix the diagnostic and expand it to allow two-way bindings to template variables that are signals.

PR Close #54714
2024-03-11 11:01:43 -07:00
Kristiyan Kostadinov ba9ddd7ac2 refactor(compiler-cli): move illegal template assignment check into template semantics checker (#54714)
Moves the check which ensures that there are no writes to template variables into the `TemplateSemanticsChecker` to prepare for the upcoming changes.

PR Close #54714
2024-03-11 11:01:43 -07:00
Kristiyan Kostadinov 5d23e601d1 refactor(compiler-cli): move signal identification function (#54714)
Moves the function that identifies signals into a separate file so that it can be reused outside of extended diagnostics.

PR Close #54714
2024-03-11 11:01:43 -07:00
Kristiyan Kostadinov a7872ca77a refactor(compiler-cli): introduce template semantics checker (#54714)
Introduces a new `TemplateSemanticsChecker` that will be used to flag semantic errors in the user's template. Currently we do some of this in the type check block, but the problem is that it doesn't have access to the template type checker which prevents us from properly checking cases like #54670. This pass is also distinct from the extended template checks, because we don't want users to be able to turn the checks off and we want them to run even if `strictTemplates` are disabled.

PR Close #54714
2024-03-11 11:01:43 -07:00
Matthieu Riegler 10314787d6 docs: adding signal inputs and model to ADEV. (#54753)
Those docs are a copy from AIO.

PR Close #54753
2024-03-11 11:01:01 -07:00
theRealc0d3c 40a94b3ab6 docs: fix typo in tutorial (#54817)
PR Close #54817
2024-03-11 10:59:53 -07:00
Kristiyan Kostadinov 1f129f114e fix(compiler): not catching for loop empty tracking expressions (#54772)
Fixes that the template parser wasn't catching empty expressions in the `track` parameter of for loops.

Fixes #54763.

PR Close #54772
2024-03-11 09:17:43 -07:00
Kristiyan Kostadinov 962934bc4f build: update to TypeScript 5.4 stable (#54743)
Updates the repo to the stable version of TypeScript 5.4.

PR Close #54743
2024-03-11 09:16:55 -07:00
JoostK 6ce8fc8637 refactor(core): avoid additional closure for queued microtask callback (#54801)
The `runCallbackOnce` closure is declared not to have any parameters itself, so it is
compatible as `queueMicrotask` callback without the extra closure. This reduces the call
stack by a frame and avoids the extra closure allocation.

PR Close #54801
2024-03-11 08:59:55 -07:00
JoostK e82173e8c4 refactor(core): properly type withBody and withHead testing helpers (#54801)
This commit addresses a typing mismatch, where these functions were declared to return whichever
value their callback returned, but this was inaccurate: it's always a test callback function
with `done` argument.

PR Close #54801
2024-03-11 08:59:55 -07:00
JoostK b6a7d509fe test(core): ensure async tests are awaited properly (#54801)
The assertion in `packages/core/test/acceptance/after_render_hook_spec.ts:165` was prone to flakes,
where Jasmine could frequently report an error:

```
Error: 'expect' was used when there was no current spec, this could be because an asynchronous test timed out
    at Env.expect (node_modules/jasmine-core/lib/jasmine-core/jasmine.js:1945:15)
    at expect (node_modules/jasmine-core/lib/jasmine-core/jasmine.js:8267:18)
    at file:///packages/core/test/acceptance/after_render_hook_spec.ts:165:12
```

This happens because `wrapTestFn` checks for an exact type of `Promise`, which may have been patched by zone.js
such that the `instanceof` condition is dependent on whether zone.js has patched the `Promise` constructor.

PR Close #54801
2024-03-11 08:59:55 -07:00
Kristiyan Kostadinov 01722ee637 build: fix size failure (#54756)
Fixes a broken size CI check.

PR Close #54756
2024-03-07 16:13:44 -08:00
Andrew Scott 7225485311 fix(router): Navigations triggered by cancellation events should cancel previous navigation (#54710)
There is an edge case where synchronous navigations caused in
response to navigation events can result in a previous navigation not
being unsubscribed from. b/328219996

PR Close #54710
2024-03-07 13:07:10 -08:00
Andrew Scott a45e69f627 refactor(core): render hooks should always run on node attach or detach (#54083)
This commit ensures that render hooks are rerun when a node is attached
or detached. We do not necessarily need to run change detection but DOM
did change so render hooks should execute.

PR Close #54083
2024-03-07 12:59:11 -08:00
Andrew Scott a7d6c64a47 refactor(core): Skip refresh views if render hooks are the only notification source (#54083)
Do not refresh views if the only thing that notified the scheduler was
registration of a new render hook.

PR Close #54083
2024-03-07 12:59:10 -08:00
Andrew Scott 2f7c7b3833 refactor(core): registering afterRender hooks notify scheduler (#54083)
This commit updates the `afterRender` and `afterNextRender` hooks to
notify the scheduler (which subsequently schedules change detection)
when created. This makes the hooks similar to `requestAnimationFrame`,
which requests that the browser schedule a rendering operation. This
reqeust is not conditional. Even if there was nothing to repaint, the
`requestAnimationFrame` callback will execute.

In Angular, this is useful because callers of `afterNextRender` don't
necessarily have any way of knowing whether a change detection is even
scheduled. For example, the anchor scrolling with the Angular Router
needs to wait for rendering to complete before attempting to scroll
because rendering can affect the size of the page. However, if the user
is already on the page that the navigation is targeting, such as
navigating to an anchor on the page, there is nothing new for the Router
to render so a render might not even be scheduled.

Related to https://github.com/angular/angular/issues/53985, which
could use `afterNextRender` instead of `setTimeout` to ensure the
scrolling happens in the same frame as the page rendering, but would not
necessarily work without this change (as described above). Note that the
scrolling _cannot_ use a microtask to ensure scrolling happens in the
same frame because `NgZone` will ensure microtasks flush before
change detection, so it would cause the scroll to happen before rendering.

PR Close #54083
2024-03-07 12:59:10 -08:00
Gerald Monaco 0daa1ea19d refactor(core): refactor i18n node creation to prepare for hydration (#54722)
Adds a `locateOrCreateNode` helper and uses it for all DOM operations in preparation for supporting hydration.

PR Close #54722
2024-03-07 12:41:46 -08:00
Leonardo Ortiz 658ae699d8 docs: update content readme link (#53763)
PR Close #53763
2024-03-07 11:07:09 -08:00
Leonardo Ortiz 9cf6b9369e docs: readme from blossom (#53763)
PR Close #53763
2024-03-07 11:07:09 -08:00
Angular Robot 9ea0d69275 docs: update events (#54721)
Generated `events.json` with the latest events retrieved from the Firebase DB.

PR Close #54721
2024-03-07 11:05:39 -08:00
Gerald Monaco f1991d49b0 refactor(core): don't hydrate detached nodes (#54723)
In preparation for hydration support, make sure that we don't attempt to hydrate detached nodes and instead, always newly create them.

PR Close #54723
2024-03-07 11:01:31 -08:00
Gerald Monaco 198f2ff5f2 refactor(core): add i18n AST to prepare for hydration (#54724)
In order to serialize and hydrate i18n blocks, we need to be able to walk an AST for the translated message. This AST is generated during normal parsing of the message.

PR Close #54724
2024-03-07 11:00:38 -08:00
Alan Agius 8d37ed035c fix(http): exclude caching for authenticated HTTP requests (#54746)
This update modifies the transfer cache logic to prevent caching of HTTP requests that require authorization.

Closes: #54745

PR Close #54746
2024-03-07 11:00:03 -08:00
Andrew Scott 365955322f refactor(core): Remove isInternal flag (#54740)
This is not needed any longer since the changes that used it have
landed.

PR Close #54740
2024-03-07 09:07:01 -08:00
Andrew Scott d870856840 refactor(core): Fix internal test util types (#54736)
This fixes and error that has been seen recently where compilation fails
due to domino.impl type not being recognized

PR Close #54736
2024-03-06 13:34:55 -08:00
Pawel Kozlowski 7bfa3610c5 release: cut the v17.3.0-rc.0 release 2024-03-06 20:40:30 +01:00
130 changed files with 2719 additions and 1629 deletions
+41 -40
View File
@@ -1,3 +1,44 @@
<a name="17.3.0"></a>
# 17.3.0 (2024-03-13)
### compiler
| Commit | Type | Description |
| -- | -- | -- |
| [1a6beae8a2](https://github.com/angular/angular/commit/1a6beae8a2bdcff27d4c1e402f98246a52247906) | feat | Enable template pipeline by default. ([#54571](https://github.com/angular/angular/pull/54571)) |
| [f386a04c9d](https://github.com/angular/angular/commit/f386a04c9ddc2951c8105d5cafcce7b4bedea569) | fix | handle two-way bindings to signal-based template variables in instruction generation ([#54714](https://github.com/angular/angular/pull/54714)) |
| [1f129f114e](https://github.com/angular/angular/commit/1f129f114edb21137d74471dd6b652848d2ffb68) | fix | not catching for loop empty tracking expressions ([#54772](https://github.com/angular/angular/pull/54772)) |
### compiler-cli
| Commit | Type | Description |
| -- | -- | -- |
| [12dc4d074e](https://github.com/angular/angular/commit/12dc4d074e63edaff626003ad6136a8d122b2ba6) | fix | account for as expression in docs extraction ([#54414](https://github.com/angular/angular/pull/54414)) |
| [da7fbb40f0](https://github.com/angular/angular/commit/da7fbb40f06e6e37504f69e7b335f8219f424de2) | fix | detect when the linker is working in unpublished angular and widen supported versions ([#54439](https://github.com/angular/angular/pull/54439)) |
| [492e03f699](https://github.com/angular/angular/commit/492e03f699ca95df4cf854885253f63a5a1fb5c1) | fix | flag two-way bindings to non-signal values in templates ([#54714](https://github.com/angular/angular/pull/54714)) |
| [5afa4f0ec1](https://github.com/angular/angular/commit/5afa4f0ec1b64b88ef875d48bd143e0f36e0a955) | fix | support `ModuleWithProviders` literal detection with `typeof` ([#54650](https://github.com/angular/angular/pull/54650)) |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [331b16efd2](https://github.com/angular/angular/commit/331b16efd2f5af876e6dc0ad2474ee7a87b00de5) | feat | add API to inject attributes on the host node ([#54604](https://github.com/angular/angular/pull/54604)) |
| [fb540e169a](https://github.com/angular/angular/commit/fb540e169a78a61f38d611f538eea8fdb0971f1d) | feat | add migration for invalid two-way bindings ([#54630](https://github.com/angular/angular/pull/54630)) |
| [c687b8f453](https://github.com/angular/angular/commit/c687b8f4531252cd1c3dfbb9a7bd42bdbe666a36) | feat | expose new `output()` API ([#54650](https://github.com/angular/angular/pull/54650)) |
| [c809069f21](https://github.com/angular/angular/commit/c809069f213244afd0e2d803a6a43510b218e6f5) | feat | introduce `outputFromObservable()` interop function ([#54650](https://github.com/angular/angular/pull/54650)) |
| [aff65fd1f4](https://github.com/angular/angular/commit/aff65fd1f4a61ed76a6f9b623852f197eb3500e4) | feat | introduce `outputToObservable` interop helper ([#54650](https://github.com/angular/angular/pull/54650)) |
| [974958913c](https://github.com/angular/angular/commit/974958913ca632971f878a045537472f2c99c665) | feat | support TypeScript 5.4 ([#54414](https://github.com/angular/angular/pull/54414)) |
| [39a50f9a8d](https://github.com/angular/angular/commit/39a50f9a8df5afc4968d18924f3d9d7d6b649d3a) | fix | ensure all initializer functions run in an injection context ([#54761](https://github.com/angular/angular/pull/54761)) |
| [243ccce624](https://github.com/angular/angular/commit/243ccce62475ae03a2e727d2b3cb2d51a595c4a7) | fix | exclude class attribute intended for projection matching from directive matching ([#54800](https://github.com/angular/angular/pull/54800)) |
| [2909e9817d](https://github.com/angular/angular/commit/2909e9817daf69b6478d7d09229491a9a48cff16) | fix | prevent infinite loops in clobbered elements check ([#54425](https://github.com/angular/angular/pull/54425)) |
| [7243c704cf](https://github.com/angular/angular/commit/7243c704cf8a4986fae419793027458e142658f0) | fix | return a readonly signal on `asReadonly`. ([#54706](https://github.com/angular/angular/pull/54706)) |
| [bb35414a38](https://github.com/angular/angular/commit/bb35414a38f8a8447d03c66dc5bf60b84409f9e3) | perf | speed up retrieval of `DestroyRef` in `EventEmitter` ([#54748](https://github.com/angular/angular/pull/54748)) |
### http
| Commit | Type | Description |
| -- | -- | -- |
| [8d37ed035c](https://github.com/angular/angular/commit/8d37ed035c9e9796ba9a7b1f055404ea220dbb3b) | fix | exclude caching for authenticated HTTP requests ([#54746](https://github.com/angular/angular/pull/54746)) |
### router
| Commit | Type | Description |
| -- | -- | -- |
| [c1c7384e02](https://github.com/angular/angular/commit/c1c7384e02becc623c6a42985f7178ca98137264) | feat | Add reusable types for router guards ([#54580](https://github.com/angular/angular/pull/54580)) |
| [7225485311](https://github.com/angular/angular/commit/722548531108e247660ebe6966b99b57d510b615) | fix | Navigations triggered by cancellation events should cancel previous navigation ([#54710](https://github.com/angular/angular/pull/54710)) |
<!-- CHANGELOG SPLIT MARKER -->
<a name="17.2.4"></a>
# 17.2.4 (2024-03-06)
### compiler-cli
@@ -38,46 +79,6 @@
<!-- CHANGELOG SPLIT MARKER -->
<a name="17.3.0-next.1"></a>
# 17.3.0-next.1 (2024-02-27)
### common
| Commit | Type | Description |
| -- | -- | -- |
| [707bfc9b32](https://github.com/angular/angular/commit/707bfc9b326f321f302dccfdfebef7380c914eb5) | perf | `AsyncPipe` should not call `markForCheck` on subscription ([#54554](https://github.com/angular/angular/pull/54554)) |
### compiler
| Commit | Type | Description |
| -- | -- | -- |
| [1a6beae8a2](https://github.com/angular/angular/commit/1a6beae8a2bdcff27d4c1e402f98246a52247906) | feat | Enable template pipeline by default. ([#54571](https://github.com/angular/angular/pull/54571)) |
### compiler-cli
| Commit | Type | Description |
| -- | -- | -- |
| [12dc4d074e](https://github.com/angular/angular/commit/12dc4d074e63edaff626003ad6136a8d122b2ba6) | fix | account for as expression in docs extraction ([#54414](https://github.com/angular/angular/pull/54414)) |
| [f578889ca2](https://github.com/angular/angular/commit/f578889ca2dbb22f2e050d4c4af6aa2d597bf70d) | fix | catch function instance properties in interpolated signal diagnostic ([#54325](https://github.com/angular/angular/pull/54325)) |
| [f04ecc0cda](https://github.com/angular/angular/commit/f04ecc0cdaeb3a292a748b1ccc94ce70a573fc79) | fix | identify aliased initializer functions ([#54480](https://github.com/angular/angular/pull/54480)) |
| [f5c566c079](https://github.com/angular/angular/commit/f5c566c0793eacf9ca146c8a6b8da15b0e8f4c4d) | fix | identify aliased initializer functions ([#54609](https://github.com/angular/angular/pull/54609)) |
### core
| Commit | Type | Description |
| -- | -- | -- |
| [331b16efd2](https://github.com/angular/angular/commit/331b16efd2f5af876e6dc0ad2474ee7a87b00de5) | feat | add API to inject attributes on the host node ([#54604](https://github.com/angular/angular/pull/54604)) |
| [974958913c](https://github.com/angular/angular/commit/974958913ca632971f878a045537472f2c99c665) | feat | support TypeScript 5.4 ([#54414](https://github.com/angular/angular/pull/54414)) |
| [dcb9deb363](https://github.com/angular/angular/commit/dcb9deb3631ff4e839f738f2fb97ca6b894256ef) | fix | collect providers from NgModules while rendering `@defer` block ([#52881](https://github.com/angular/angular/pull/52881)) |
| [ff40c9f762](https://github.com/angular/angular/commit/ff40c9f762b2c14870ac2859201ece9660087e79) | fix | fix typo in injectors.svg file ([#54596](https://github.com/angular/angular/pull/54596)) |
### migrations
| Commit | Type | Description |
| -- | -- | -- |
| [71e0c7df69](https://github.com/angular/angular/commit/71e0c7df695f274a54d5ffb9dfc0587d06d3a953) | fix | resolve infinite loop for a single line element with a long tag name and angle bracket on a new line ([#54588](https://github.com/angular/angular/pull/54588)) |
<!-- CHANGELOG SPLIT MARKER -->
<a name="17.3.0-next.0"></a>
# 17.3.0-next.0 (2024-02-21)
### compiler-cli
| Commit | Type | Description |
| -- | -- | -- |
| [da7fbb40f0](https://github.com/angular/angular/commit/da7fbb40f06e6e37504f69e7b335f8219f424de2) | fix | detect when the linker is working in unpublished angular and widen supported versions ([#54439](https://github.com/angular/angular/pull/54439)) |
<!-- CHANGELOG SPLIT MARKER -->
<a name="17.2.2"></a>
# 17.2.2 (2024-02-21)
### common
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License
Copyright (c) 2010-2023 Google LLC. https://angular.io/license
Copyright (c) 2010-2024 Google LLC. https://angular.io/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-135
View File
@@ -1,135 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {input, confirm} from '@inquirer/prompts';
import {existsSync} from 'fs';
import {copyFile, mkdir, writeFile} from 'fs/promises';
import {dirname, join} from 'path';
import {fileURLToPath} from 'url';
import {copyFolder} from './utils/fs.mjs';
const EXAMPLES_BASE = join(
dirname(fileURLToPath(import.meta.url)),
'../../src/content/examples',
);
const STACKBLITZ_TEMPLATE = join(
dirname(fileURLToPath(import.meta.url)),
'../../src/content/stackblitz-template',
);
const MAIN_TS_FILENAME = 'main.ts';
const STACKBLITZ_CONFIG_FILENAME = 'stackblitz.json';
createExample();
async function createExample(): Promise<void> {
console.info('Create example');
const name = await input({message: 'What is the example name?'});
const description = await input({message: 'Could you provide the description of the example?'});
const tags: string[] = [];
let addMoreTags = true;
while (addMoreTags) {
const tag = await input({message: 'Could you provide the tag of the example?'});
if (!!tag) {
tags.push(tag);
}
console.info();
addMoreTags = await confirm({message: 'Would you like to add another tag?', default: true});
}
const shouldCopySrcAppFolderFromTemplate = await confirm({
message: 'Would you like to copy /src/app/**.* from template?',
default: true,
});
const shouldCopyMainTsFromTemplate = await confirm({
message: 'Would you like to copy /src/main.ts from template?',
default: false,
});
const examplePath = join(EXAMPLES_BASE, name);
await createEmptyExample(examplePath, name, description, tags);
await copyFilesFromTemplate(
examplePath,
shouldCopySrcAppFolderFromTemplate,
shouldCopyMainTsFromTemplate,
);
}
/**
* Create the directory and marker files for the new example.
*/
async function createEmptyExample(
examplePath: string,
exampleName: string,
description: string,
tags: string[],
): Promise<void> {
validateExampleName(exampleName);
await ensureExamplePath(examplePath);
await writeStackBlitzFile(examplePath, description, tags);
}
function validateExampleName(exampleName: string): void {
if (/\s/.test(exampleName)) {
throw new Error(`Unable to create example. The example name contains spaces: '${exampleName}'`);
}
}
/**
* Ensure that the new example directory exists.
*/
async function ensureExamplePath(examplePath: string): Promise<void> {
if (existsSync(examplePath)) {
throw new Error(
`Unable to create example. The path to the new example already exists: ${examplePath}`,
);
}
await mkdir(examplePath);
}
/**
* Write the `stackblitz.json` file into the new example.
*/
async function writeStackBlitzFile(
examplePath: string,
description: string,
tags: string[],
): Promise<void> {
const config = {
description,
ignore: ['**/*.d.ts', '**/*.js', '**/*.[1,2].*'],
tags,
};
await writeFile(
join(examplePath, STACKBLITZ_CONFIG_FILENAME),
JSON.stringify(config, null, 2) + '\n',
);
}
async function copyFilesFromTemplate(
examplePath: string,
shouldCopySrcAppFolderFromTemplate: boolean,
shouldCopyMainTsFromTemplate: boolean,
): Promise<void> {
if (shouldCopySrcAppFolderFromTemplate) {
const appTemplateFolderPath = join(STACKBLITZ_TEMPLATE, 'src/app');
const exampleAppFolderPath = join(examplePath, 'src/app');
await copyFolder(appTemplateFolderPath, exampleAppFolderPath);
}
if (shouldCopyMainTsFromTemplate) {
const mainTsTemplatePath = join(STACKBLITZ_TEMPLATE, 'src', MAIN_TS_FILENAME);
const exampleMainTsPath = join(examplePath, 'src', MAIN_TS_FILENAME);
await copyFile(mainTsTemplatePath, exampleMainTsPath);
}
}
@@ -1,158 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import ts from 'typescript';
import * as fs from 'fs';
import {dirname, join, relative} from 'path';
import {fileURLToPath} from 'url';
import {readFile, writeFile} from 'fs/promises';
const TYPESCRIPT_EXTENSION = '.ts';
const SKIP_FILES_WITH_EXTENSIONS = ['.e2e-spec.ts', '.spec.ts', '.po.ts'];
const EXAMPLES_PATH = `../../content/examples`;
interface File {
path: string;
content: string;
}
interface AnalyzedFiles {
path: string;
componentNames: string[];
}
/** Absolute disk path to the project directory. */
const examplesDir = join(
dirname(fileURLToPath(import.meta.url)),
'../../src/content/examples',
);
const projectDir = join(dirname(fileURLToPath(import.meta.url)), '../../src');
const previewsComponentMapFileName = 'previews.ts';
main();
async function main() {
const files = await retrieveAllTypescriptFiles(
examplesDir,
(path) => !SKIP_FILES_WITH_EXTENSIONS.some((extensionToSkip) => path.endsWith(extensionToSkip)),
);
const filesWithComponent = files
.map((file) => ({
componentNames: analyzeFile(file),
path: file.path,
}))
.filter((result) => result.componentNames.length > 0);
const previewsComponentMap = generatePreviewsComponentMap(filesWithComponent);
fs.mkdirSync(join(projectDir, 'assets/previews'), {recursive: true});
await writeFile(
join(projectDir, 'assets/previews', previewsComponentMapFileName),
previewsComponentMap,
);
}
/** Recursively search the provided directory for all typescript files and asynchronously load them. */
function retrieveAllTypescriptFiles(
baseDir: string,
predicateFn: (path: string) => boolean,
): Promise<File[]> {
const typescriptFiles: Promise<File>[] = [];
const checkFilesInDirectory = (dir: string) => {
const files = fs.readdirSync(dir, {withFileTypes: true});
for (const file of files) {
const fullPathToFile = join(dir, file.name);
const relativeFilePath = relative(baseDir, fullPathToFile);
if (
file.isFile() &&
file.name.endsWith(TYPESCRIPT_EXTENSION) &&
predicateFn(relativeFilePath)
) {
typescriptFiles.push(
readFile(fullPathToFile, {encoding: 'utf-8'}).then((fileContent) => {
return {
path: relativeFilePath,
content: fileContent,
};
}),
);
} else if (file.isDirectory()) {
checkFilesInDirectory(fullPathToFile);
}
}
};
checkFilesInDirectory(baseDir);
return Promise.all(typescriptFiles);
}
/** Returns list of the `Standalone` @Component class names for given file */
function analyzeFile(file: File): string[] {
const componentClassNames: string[] = [];
const sourceFile = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, false);
const visitNode = (node: ts.Node): void => {
if (ts.isClassDeclaration(node)) {
const decorators = ts.getDecorators(node);
const componentName = node.name ? node.name.text : null;
if (decorators && decorators.length) {
for (const decorator of decorators) {
const call = decorator.expression;
if (
ts.isCallExpression(call) &&
ts.isIdentifier(call.expression) &&
call.expression.text === 'Component' &&
call.arguments.length > 0 &&
ts.isObjectLiteralExpression(call.arguments[0])
) {
const hasStandaloneProperty = call.arguments[0].properties.some(
(property) =>
property.name &&
ts.isIdentifier(property.name) &&
property.name.text === 'standalone',
);
if (hasStandaloneProperty && componentName) {
componentClassNames.push(componentName);
}
}
}
}
}
ts.forEachChild(node, visitNode);
};
visitNode(sourceFile);
return componentClassNames;
}
function generatePreviewsComponentMap(data: AnalyzedFiles[]): string {
let result = '';
for (const fileData of data) {
for (const componentName of fileData.componentNames) {
const key = `${fileData.path}${
fileData.componentNames.length > 1 ? '_' + componentName : ''
}`.replace(/\\/g, '/');
result += `['${key}']: () => import('${EXAMPLES_PATH}/${fileData.path
.replace(/\\/g, '/')
.replace('.ts', '')}').then(c => c.${componentName}),\n`;
}
}
return fs
.readFileSync('scripts/examples/previews-components.template', 'utf8')
.replace(/\${previewsComponents}/g, result);
}
@@ -1,23 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/**
******************************************************************************
* DO NOT MANUALLY EDIT THIS FILE. THIS FILE IS AUTOMATICALLY GENERATED.
******************************************************************************
*/
import {Type} from '@angular/core';
/**
* Map of the previews components, values are functions which returns the promise of the component type, which will be displayed as preview in the ExampleViewer component.
* Keys has to be equal to paths written down in the docs markdown files.
*/
export const PREVIEWS_COMPONENTS_MAP: Record<string, () => Promise<Type<unknown>>> = {
${previewsComponents}
};
@@ -1,221 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {dirname, join} from 'path';
import {readFile, writeFile} from 'fs/promises';
import {
ASSETS_EXAMPLE_PATH,
CSS_TS_COPYRIGHT,
EXAMPLES_PATH,
HTML_COPYRIGHT,
STACKBLITZ_TEMPLATE_PATH,
STACKBLITZ_CONFIG_FILENAME,
TEMPORARY_EXAMPLES_PATH,
EXCLUDE_FILES_FOR_STACKBLITZ,
} from './utils/examples-constants.mjs';
import {copyFolder, createFolder, removeFolder} from './utils/fs.mjs';
import jsdom from 'jsdom';
import {glob} from 'glob';
import {regionParser} from './../../prerender/markdown-pipeline/regions/region-parser.mjs';
interface StackblitzConfig {
ignore: string[];
file: string;
tags: string[];
description: string;
}
export async function generateStackblitzExample(
exampleFolderName: string,
primaryFilePath: string,
title: string,
) {
const exampleDir = join(EXAMPLES_PATH, exampleFolderName);
const temporaryExampleDir = join(TEMPORARY_EXAMPLES_PATH, exampleFolderName);
const config = await readFile(join(exampleDir, STACKBLITZ_CONFIG_FILENAME), 'utf-8');
const stackblitzConfig: StackblitzConfig = JSON.parse(config);
primaryFilePath = join(...primaryFilePath.split('/'));
await createFolder(temporaryExampleDir);
await combineTemplateWithExample(exampleDir, temporaryExampleDir);
await generateStackblitzHtml(
temporaryExampleDir,
stackblitzConfig,
exampleFolderName,
primaryFilePath,
title,
);
await removeFolder(temporaryExampleDir);
}
async function combineTemplateWithExample(
exampleDir: string,
temporaryExampleDir: string,
): Promise<void> {
// Copy template files to TEMP folder
await copyFolder(STACKBLITZ_TEMPLATE_PATH, temporaryExampleDir);
// Copy example files to TEMP folder
await copyFolder(exampleDir, temporaryExampleDir);
}
async function generateStackblitzHtml(
temporaryExampleDir: string,
stackBlitzConfig: StackblitzConfig,
exampleFolderName: string,
primaryFilePath: string,
title: string,
): Promise<void> {
const defaultIncludes = [
'**/*.ts',
'**/*.js',
'**/*.css',
'**/*.html',
'**/*.md',
'**/*.json',
'**/*.svg',
];
const exampleFilePaths = await glob(defaultIncludes, {
cwd: temporaryExampleDir,
nodir: true,
dot: true,
ignore: stackBlitzConfig.ignore,
});
const postData = await createPostData(
temporaryExampleDir,
stackBlitzConfig,
exampleFilePaths,
title,
);
const primaryFile = getPrimaryFile(primaryFilePath ?? stackBlitzConfig.file, exampleFilePaths);
const html = createStackblitzHtml(postData, primaryFile);
const stackblitzHtmlPath = join(
join(ASSETS_EXAMPLE_PATH, exampleFolderName),
`${primaryFile}.html`,
);
await createFolder(dirname(stackblitzHtmlPath));
await writeFile(stackblitzHtmlPath, html, 'utf-8');
}
function getPrimaryFile(primaryFilePath: string, exampleFilePaths: string[]): string {
if (primaryFilePath) {
if (!exampleFilePaths.some((filePath) => filePath === primaryFilePath)) {
throw new Error(`The specified primary file (${primaryFilePath}) does not exist!`);
}
return primaryFilePath;
} else {
const defaultPrimaryFilePaths = [
'src/app/app.component.html',
'src/app/app.component.ts',
'src/app/main.ts',
];
const primaryFile = defaultPrimaryFilePaths.find((path) =>
exampleFilePaths.some((filePath) => filePath === path),
);
if (!primaryFile) {
throw new Error(
`None of the default primary files (${defaultPrimaryFilePaths.join(', ')}) exists.`,
);
}
return primaryFile;
}
}
async function createPostData(
exampleDir: string,
config: StackblitzConfig,
exampleFilePaths: string[],
title: string,
): Promise<Record<string, string>> {
const postData: Record<string, string> = {};
for (const filePath of exampleFilePaths) {
if (EXCLUDE_FILES_FOR_STACKBLITZ.some((excludedFile) => filePath.endsWith(excludedFile))) {
continue;
}
let content = await readFile(join(exampleDir, filePath), 'utf-8');
content = appendCopyright(filePath, content);
content = extractRegions(filePath, content);
postData[`project[files][${filePath}]`] = content;
}
const tags = ['angular', 'example', ...(config.tags || [])];
tags.forEach((tag, index) => (postData[`project[tags][${index}]`] = tag));
postData['project[description]'] = `Angular Example - ${config.description}`;
postData['project[template]'] = 'node';
postData['project[title]'] = title ?? 'Angular Example';
return postData;
}
function createStackblitzHtml(postData: Record<string, string>, primaryFile: string): string {
const baseHtml = createBaseStackblitzHtml(primaryFile);
const doc = new jsdom.JSDOM(baseHtml).window.document;
const form = doc.querySelector('form');
for (const [key, value] of Object.entries(postData)) {
const element = htmlToElement(doc, `<input type="hidden" name="${key}">`);
if (element && form) {
element.setAttribute('value', value as string);
form.appendChild(element);
}
}
return doc.documentElement.outerHTML;
}
function createBaseStackblitzHtml(primaryFile: string) {
const file = `?file=${primaryFile}`;
const action = `https://stackblitz.com/run${file}`;
return `
<!DOCTYPE html><html lang="en"><body>
<form id="mainForm" method="post" action="${action}" target="_self"></form>
<script>
var embedded = 'ctl=1';
var isEmbedded = window.location.search.indexOf(embedded) > -1;
if (isEmbedded) {
var form = document.getElementById('mainForm');
var action = form.action;
var actionHasParams = action.indexOf('?') > -1;
var symbol = actionHasParams ? '&' : '?'
form.action = form.action + symbol + embedded;
}
document.getElementById("mainForm").submit();
</script>
</body></html>
`.trim();
}
function appendCopyright(filename: string, content: string): string {
if (filename.endsWith('.html')) {
return `${HTML_COPYRIGHT}${content}`;
} else if (filename.endsWith('.ts') || filename.endsWith('.css')) {
return `${CSS_TS_COPYRIGHT}${content}`;
}
return content;
}
function htmlToElement(document: Document, html: string) {
const div = document.createElement('div');
div.innerHTML = html;
return div.firstElementChild;
}
function extractRegions(path: string, contents: string): string {
const regionParserResult = regionParser(contents, path);
return regionParserResult.contents;
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"ts-node": {
"compilerOptions": {
"module": "es2020"
}
}
}
@@ -1,55 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {dirname, join} from 'path';
import {fileURLToPath} from 'url';
const BASE = dirname(fileURLToPath(import.meta.url));
// Directory paths
export const ASSETS_EXAMPLE_PATH = join(
BASE,
'../../../src/assets/content/examples',
);
export const EXAMPLES_PATH = join(BASE, '../../../src/content/examples');
export const STACKBLITZ_TEMPLATE_PATH = join(
BASE,
'../../../src/content/stackblitz-template',
);
export const TEMPORARY_EXAMPLES_PATH = join(fileURLToPath(import.meta.url), '../../../../TEMP');
// Extensions
export const TEST_FILES_EXTENSION_SUFFIX = '.spec.ts';
export const TEST_FILES_E2E_EXTENSION_SUFFIX = '.e2e-spec.ts';
// Filename
// TODO: BUILD.bazel and example-config will be deprecated when migration to standalone examples will be done.
export const BUILD_BAZEL_FILENAME = 'BUILD.bazel';
export const EXAMPLE_CONFIG_FILENAME = 'example-config.json';
export const STACKBLITZ_CONFIG_FILENAME = 'stackblitz.json';
// Copyright
const PAD = '\n\n';
const COPYRIGHT =
'@license\n' +
'Copyright Google LLC All Rights Reserved.\n' +
'\n' +
'Use of this source code is governed by an MIT-style license that can be\n' +
'found in the LICENSE file at https://angular.dev/license\n';
export const CSS_TS_COPYRIGHT = `/*\n${COPYRIGHT}\n*/${PAD}`;
export const HTML_COPYRIGHT = `<!-- \n${COPYRIGHT}\n-->${PAD}`;
// Exclude
export const EXCLUDE_FILES_FOR_STACKBLITZ = [
STACKBLITZ_CONFIG_FILENAME,
BUILD_BAZEL_FILENAME,
EXAMPLE_CONFIG_FILENAME,
TEST_FILES_EXTENSION_SUFFIX,
TEST_FILES_E2E_EXTENSION_SUFFIX,
];
-77
View File
@@ -1,77 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Dirent, existsSync, readdirSync} from 'fs';
import {copyFile, mkdir, readFile, readdir, rm, stat} from 'fs/promises';
import {join, relative} from 'path';
export async function copyFolder(source: string, destination: string) {
if (!existsSync(destination)) {
await mkdir(destination, {recursive: true});
}
const files = await readdir(source);
for (const file of files) {
const sourcePath = join(source, file);
const destPath = join(destination, file);
const stats = await stat(sourcePath);
const isDirectory = await stats.isDirectory();
if (isDirectory) {
await copyFolder(sourcePath, destPath);
} else {
await copyFile(sourcePath, destPath);
}
}
}
/** Recursively search the provided directory for all files which satisfy predicate and asynchronously load them. */
export function retrieveFiles(
baseDir: string,
predicate?: (file: Dirent) => boolean,
): Promise<{path: string; content: string}[]> {
const filesWithContent: Promise<{path: string; content: string}>[] = [];
const checkFilesInDirectory = (dir: string) => {
const files = readdirSync(dir, {withFileTypes: true});
for (const file of files) {
const fullPathToFile = join(dir, file.name);
if (file.isFile() && (predicate ? predicate(file) : true)) {
filesWithContent.push(
readFile(fullPathToFile, {encoding: 'utf-8'}).then((fileContent) => {
return {
path: relative(baseDir, fullPathToFile),
content: fileContent,
};
}),
);
} else if (file.isDirectory()) {
checkFilesInDirectory(fullPathToFile);
}
}
};
checkFilesInDirectory(baseDir);
return Promise.all(filesWithContent);
}
export async function createFolder(path: string): Promise<void> {
if (!existsSync(path)) {
await mkdir(path, {recursive: true});
}
}
export async function removeFolder(path: string): Promise<void> {
if (existsSync(path)) {
await rm(path, {recursive: true});
}
}
@@ -116,7 +116,7 @@
</div>
</div>
<p class="docs-license">
Super-powered by Google ©2010-2023. Code licensed under an
Super-powered by Google ©2010-2024. Code licensed under an
<a routerLink="/license" title="License text">MIT-style License</a>
. Documentation licensed under
<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>
+207
View File
@@ -0,0 +1,207 @@
# EmbeddedEditor components, services and functionality
- [Scenarios](#scenarios)
- [Loading a project](#loading-a-project)
- [Updating the code](#updating-the-code)
- [Creating a new file](#creating-a-new-file)
- [Deleting a file](#deleting-a-file)
- [Switching a project](#switching-a-project)
- [Components and services](#components-and-services)
- [EmbeddedEditor](#EmbeddedEditor)
- [CodeEditor](#CodeEditor)
- [CodeMirrorEditor](#CodeMirrorEditor)
- [TypeScript Web Worker](#typescript-web-worker)
- [Preview](#Preview)
- [Terminal](#Terminal)
- [InteractiveTerminal](#InteractiveTerminal)
- [Console](#Console)
- [NodeRuntimeSandbox](#NodeRuntimeSandbox)
- [NodeRuntimeState](#NodeRuntimeState)
- [EmbeddedTutorialManager](#EmbeddedTutorialManager)
- [EditorUiState](#EditorUiState)
- [DownloadManager](#DownloadManager)
- [AlertManager](#AlertManager)
- [TypingsLoader](#TypingsLoader)
## External libraries
- [WebContainers API](https://webcontainers.io/)
- [CodeMirror](https://codemirror.net/)
- [@typescript/vfs](https://www.npmjs.com/package/@typescript/vfs)
- [Xterm.js](https://xtermjs.org/)
## Notes
- See [scripts/tutorials/README.md](/scripts/tutorials/README.md) for more information about the tutorials script.
- See [adev/src/content/tutorials/README.md](/adev/src/content/tutorials/README.md) for more information about the tutorials content.
---
## Scenarios
### Loading a project
1. The page responsible for the embedded editor lazy loads the [`EmbeddedEditor`](./embedded-editor.component.ts) component and the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts), then triggers the initialization of all components and services. The embedded editor is available in the following pages:
- homepage: https://angular.dev
- playground: https://angular.dev/playground
- tutorial pages: https://angular.dev/tutorials
2. The project assets are fetched by the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts). Meanwhile:
- The code editor is initialized
- The code editor initializes the TypeScript Web Worker, which initializes the "default file system map" using TypeScript's CDN.
- The WebContainer is initialized
- The terminal is initialized
3. The tutorial source code is mounted in the `WebContainer`'s filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts)
4. The tutorial project dependencies are installed by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
5. The development server is started by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts) and the types are loaded by the [`TypingsLoader`](./typings-loader.service.ts) service.
6. The preview is loaded with the URL provided by the WebContainer API after the development server is started.
7. The project is ready.
### Updating the code
1. The user update the code in the code editor.
2. The code editor state is updated on real time, without debouncing so that the user can see the changes in the code editor and CodeMirror can handle the changes accordingly.
3. At the same time, the changes are sent to the TypeScript web worker to provide diagnostics, autocomplete and type features as soon as possible.
4. The code changes are debounced to be written in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
5. After the debounce time is reached, the code changes are written in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts), then the user can see the changes in the preview.
### Creating a new file
1. The user clicks on the new file button.
2. The new file tab is opened.
3. The user types the new file name.
4. If the file name is valid, the file is created in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
- `..` is disallowed in the file name to prevent users to create files outside the `src` directory.
5. The file is added to the TypeScript virtual file system, allowing the TypeScript web worker to provide diagnostics, autocomplete and type features for the new file. Also, exports from the new file are available in other files.
6. The new file is added as the last tab in the code editor and the new file can be edited.
Note: If the new file name matches a file that already exists but is hidden in the code editor, the content for that file will show up in the created file. An example for a file that always exists is `index.html`.
### Deleting a file
1. The user clicks on the delete file button.
2. The file is deleted from the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
3. The file is removed from the TypeScript virtual file system.
4. The file is removed from the code editor tabs.
Note: Some files can't be deleted to prevent users to break the app, being `src/main.ts`and `src/index.html`
### Switching a project
The embedded editor considers a project change when the embedded editor was already initialized and the user changes the page in the following scenarios:
- Navigating through tutorial steps
- Going from the homepage after the embedded editor is initialized to the playground
- Going from a tutorial page to the playground
- Going from a tutorial page to the homepage
- Going from the playground to the homepage
When a project change is detected, the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts) emits the `tutorialChanged` observable, which is listened in multiple sub-components and services, then each component/service performs the necessary operations to switch the project.
The following steps are executed on project change:
1. The new project files are fetched by the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts).
2. The new project files are mounted in the WebContainer filesystem.
3. The TypeScript virtual filesystem is updated with the new files and contents.
4. The previous project and new project files are compared.
1. Files that are not available in the new project are deleted from the WebContainer filesystem.
2. Files that have the same path and name have their content replaced on the previous step when the files are mounted.
5. The previous project dependencies are compared with the new project dependencies.
1. If there are differences, a `npm install` is triggered, hiding the preview and going to the install loading step.
2. If there are no differences, the project is ready.
6. Some states are resetted, for example the "reveal answer" state if the previous project was in the "reveal answer" state.
## Components and services
### [`EmbeddedEditor`](./embedded-editor.component.ts)
The embedded editor is the parent component that holds all the components and services that compose the embedded editor.
#### [`CodeEditor`](./code-editor/code-editor.component.ts)
The component that holds the code editor view and the code editor state.
##### [`CodeMirrorEditor`](./code-editor/code-mirror-editor.service.ts)
[CodeMirror](https://codemirror.net/) is the library used to handle the code editor.
The `CodeMirrorEditor` service manages the CodeMirror instance and all the interactions with the library used to handle the code editor.
- handle the file edits and the CodeMirror view and state
- handle the current project files in the code editor
- handle the file creations and deletions
- handle the file changes
- handle all the CodeMirror specific events and extensions
###### [TypeScript Web Worker](./code-editor/workers/typescript-vfs.worker.ts)
The TypeScript features are provided by the TypeScript web worker, that is initialized by the `CodeMirrorEditor` service.
The TypeScript web worker uses `@typescript/vfs` and the TypeScript language service to provide diagnostics, autocomplete and type features.
#### [`Preview`](./preview/preview.component.ts)
The preview component manages the `iframe` responsible for displaying the tutorial project preview, with the URL provided by the WebContainer API after the development server is started.
While the project is being initialized, the preview displays the loading state.
#### [`Terminal`](./terminal/terminal.component.ts)
[Xterm.js](https://xtermjs.org/) is the library used to handle the terminals.
The terminal component handles the Xterm.js instance for the console and for the interactive terminal.
##### [`InteractiveTerminal`](./terminal/interactive-terminal.ts)
The interactive terminal is the terminal where the user can interact with the terminal and run commands, supporting only commands for the Angular CLI.
##### Console
The console displays the output for `npm install` and `ng serve`.
#### [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts)
Responsible for managing the WebContainer instance and all communication with its API. This service handles:
- the WebContainer instance
- all Node.js scripts
- the WebContainer filesystem, mounting the tutorial project files, writing new content, deleting and creating files.
- the terminal session, reading and processing user inputs.
- the tutorial project dependencies, installing the dependencies.
- the processes running inside the WebContainer, being the npm scripts to install the dependencies, run the development server and the user inputs for the `ng` CLI.
##### [`NodeRuntimeState`](./node-runtime-state.service.ts)
Manages the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts) loading and error state.
#### [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts)
Manages the tutorial assets, being responsible for fetching the tutorial source code and metadata.
The source code is mounted in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
The metadata is used to manage the project, handle the project changes and the user interactivity with the app.
This service also handles the reveal answer and reset reveal answer feature.
#### [`EditorUiState`](./editor-ui-state.service.ts)
Manages the editor UI state, being responsible for handling the user interactions with the editor tabs, switching between the preview, the terminal and the console.
#### [`DownloadManager`](./download-manager.service.ts)
Responsible for handling the download button in the embedded editor, fetching the tutorial project files and generating a zip file with the project content.
#### [`AlertManager`](./alert-manager.service.ts)
Manage the alerts displayed in the embedded editor, being the out of memory alert when multiple tabs are opened, and unsupported enviroments alerts.
#### [`TypingsLoader`](./typings-loader.service.ts)
Manages the types definitions for the code editor.
+187 -172
View File
@@ -157,6 +157,11 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'guide/components/advanced-configuration',
contentPath: 'guide/components/advanced-configuration',
},
{
label: 'Custom Elements',
path: 'guide/elements',
contentPath: 'guide/elements',
},
],
},
{
@@ -218,9 +223,39 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
contentPath: 'guide/templates/control-flow',
},
{
label: 'Deferrable views',
path: 'guide/defer',
contentPath: 'guide/defer',
label: 'Pipes',
children: [
{
label: 'Overview',
path: 'guide/pipes',
contentPath: 'guide/pipes/overview',
},
{
label: 'Using a pipe in a template',
path: 'guide/pipes/template',
contentPath: 'guide/pipes/template',
},
{
label: 'Custom pipes',
path: 'guide/pipes/transform-data',
contentPath: 'guide/pipes/transform-data',
},
{
label: 'Pipe precedence in expressions',
path: 'guide/pipes/precedence',
contentPath: 'guide/pipes/precedence',
},
{
label: 'Change detection with pipes',
path: 'guide/pipes/change-detection',
contentPath: 'guide/pipes/change-detection',
},
{
label: 'Unwrapping data from an observable',
path: 'guide/pipes/unwrapping-data-observables',
contentPath: 'guide/pipes/unwrapping-data-observables',
},
],
},
{
label: 'Template reference variables',
@@ -317,6 +352,16 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'guide/signals/rxjs-interop',
contentPath: 'guide/signals/rxjs-interop',
},
{
label: 'Inputs as signals',
path: 'guide/signals/inputs',
contentPath: 'guide/signals/inputs',
},
{
label: 'Model inputs',
path: 'guide/signals/model',
contentPath: 'guide/signals/model',
},
{
label: 'Queries as signals',
path: 'guide/signals/queries',
@@ -324,71 +369,6 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
],
},
{
label: 'NgModule',
children: [
{
label: 'Overview',
path: 'guide/ngmodules',
contentPath: 'guide/ngmodules/overview',
},
{
label: 'JS Modules vs NgModules',
path: 'guide/ngmodules/vs-jsmodule',
contentPath: 'guide/ngmodules/vs-jsmodule',
},
{
label: 'Launching your app with a root module',
path: 'guide/ngmodules/bootstrapping',
contentPath: 'guide/ngmodules/bootstrapping',
},
{
label: 'Sharing NgModules',
path: 'guide/ngmodules/sharing',
contentPath: 'guide/ngmodules/sharing',
},
{
label: 'Frequently used NgModules',
path: 'guide/ngmodules/frequent',
contentPath: 'guide/ngmodules/frequent',
},
{
label: 'Feature modules',
path: 'guide/ngmodules/feature-modules',
contentPath: 'guide/ngmodules/feature-modules',
},
{
label: 'Types of feature modules',
path: 'guide/ngmodules/module-types',
contentPath: 'guide/ngmodules/module-types',
},
{
label: 'Providing dependencies',
path: 'guide/ngmodules/providers',
contentPath: 'guide/ngmodules/providers',
},
{
label: 'Singleton services',
path: 'guide/ngmodules/singleton-services',
contentPath: 'guide/ngmodules/singleton-services',
},
{
label: 'Lazy-loading feature modules',
path: 'guide/ngmodules/lazy-loading',
contentPath: 'guide/ngmodules/lazy-loading',
},
{
label: 'NgModule API',
path: 'guide/ngmodules/api',
contentPath: 'guide/ngmodules/api',
},
{
label: 'NgModule FAQs',
path: 'guide/ngmodules/faq',
contentPath: 'guide/ngmodules/faq',
},
],
},
{
label: 'Routing',
children: [
@@ -455,29 +435,64 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
],
},
{
label: 'Server-side Rendering',
path: 'guide/ssr',
contentPath: 'guide/ssr',
label: 'HTTP Client',
children: [
{
label: 'Overview',
path: 'guide/http',
contentPath: 'guide/http/overview',
},
{
label: 'Setting up HttpClient',
path: 'guide/http/setup',
contentPath: 'guide/http/setup',
},
{
label: 'Making requests',
path: 'guide/http/making-requests',
contentPath: 'guide/http/making-requests',
},
{
label: 'Intercepting requests and responses',
path: 'guide/http/interceptors',
contentPath: 'guide/http/interceptors',
},
{
label: 'Testing',
path: 'guide/http/testing',
contentPath: 'guide/http/testing',
},
],
},
{
label: 'Build-time prerendering',
path: 'guide/prerendering',
contentPath: 'guide/prerendering',
},
{
label: 'Hydration',
path: 'guide/hydration',
contentPath: 'guide/hydration',
},
{
label: 'Deferrable views',
path: 'guide/defer',
contentPath: 'guide/defer',
},
{
label: 'Image Optimization',
path: 'guide/image-optimization',
contentPath: 'guide/image-optimization',
label: 'Performance',
children: [
{
label: 'Deferrable views',
path: 'guide/defer',
contentPath: 'guide/defer',
},
{
label: 'Image Optimization',
path: 'guide/image-optimization',
contentPath: 'guide/image-optimization',
},
{
label: 'Server-side Rendering',
path: 'guide/ssr',
contentPath: 'guide/ssr',
},
{
label: 'Build-time prerendering',
path: 'guide/prerendering',
contentPath: 'guide/prerendering',
},
{
label: 'Hydration',
path: 'guide/hydration',
contentPath: 'guide/hydration',
},
],
},
{
label: 'Testing',
@@ -589,81 +604,6 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
],
},
{
label: 'Security',
path: 'guide/security',
contentPath: 'guide/security',
},
{
label: 'Pipes',
children: [
{
label: 'Overview',
path: 'guide/pipes',
contentPath: 'guide/pipes/overview',
},
{
label: 'Using a pipe in a template',
path: 'guide/pipes/template',
contentPath: 'guide/pipes/template',
},
{
label: 'Custom pipes',
path: 'guide/pipes/transform-data',
contentPath: 'guide/pipes/transform-data',
},
{
label: 'Pipe precedence in expressions',
path: 'guide/pipes/precedence',
contentPath: 'guide/pipes/precedence',
},
{
label: 'Change detection with pipes',
path: 'guide/pipes/change-detection',
contentPath: 'guide/pipes/change-detection',
},
{
label: 'Unwrapping data from an observable',
path: 'guide/pipes/unwrapping-data-observables',
contentPath: 'guide/pipes/unwrapping-data-observables',
},
],
},
{
label: 'Http Client',
children: [
{
label: 'Overview',
path: 'guide/http',
contentPath: 'guide/http/overview',
},
{
label: 'Setting up HttpClient',
path: 'guide/http/setup',
contentPath: 'guide/http/setup',
},
{
label: 'Making requests',
path: 'guide/http/making-requests',
contentPath: 'guide/http/making-requests',
},
{
label: 'Intercepting requests and responses',
path: 'guide/http/interceptors',
contentPath: 'guide/http/interceptors',
},
{
label: 'Security',
path: 'guide/http/security',
contentPath: 'guide/http/security',
},
{
label: 'Testing',
path: 'guide/http/testing',
contentPath: 'guide/http/testing',
},
],
},
{
label: 'Animations',
children: [
@@ -694,11 +634,6 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
],
},
{
label: 'Custom Elements',
path: 'guide/elements',
contentPath: 'guide/elements',
},
],
},
{
@@ -830,9 +765,9 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
contentPath: 'best-practices/style-guide',
},
{
label: 'Keeping up-to-date',
path: 'update',
contentPath: 'best-practices/update',
label: 'Security',
path: 'best-practices/security',
contentPath: 'guide/security', // Have not refactored due to build issues
},
{
label: 'Accessibility',
@@ -864,6 +799,11 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
],
},
{
label: 'Keeping up-to-date',
path: 'update',
contentPath: 'best-practices/update',
},
],
},
{
@@ -1349,6 +1289,81 @@ const REFERENCE_SUB_NAVIGATION_DATA: NavigationItem[] = [
},
],
},
{
label: 'Concepts',
children: [
{
label: 'Overview',
path: 'reference/concepts',
contentPath: 'reference/concepts/overview',
},
{
label: 'NgModule',
children: [
{
label: 'Overview',
path: 'guide/ngmodules',
contentPath: 'guide/ngmodules/overview',
},
{
label: 'JS Modules vs NgModules',
path: 'guide/ngmodules/vs-jsmodule',
contentPath: 'guide/ngmodules/vs-jsmodule',
},
{
label: 'Launching your app with a root module',
path: 'guide/ngmodules/bootstrapping',
contentPath: 'guide/ngmodules/bootstrapping',
},
{
label: 'Sharing NgModules',
path: 'guide/ngmodules/sharing',
contentPath: 'guide/ngmodules/sharing',
},
{
label: 'Frequently used NgModules',
path: 'guide/ngmodules/frequent',
contentPath: 'guide/ngmodules/frequent',
},
{
label: 'Feature modules',
path: 'guide/ngmodules/feature-modules',
contentPath: 'guide/ngmodules/feature-modules',
},
{
label: 'Types of feature modules',
path: 'guide/ngmodules/module-types',
contentPath: 'guide/ngmodules/module-types',
},
{
label: 'Providing dependencies',
path: 'guide/ngmodules/providers',
contentPath: 'guide/ngmodules/providers',
},
{
label: 'Singleton services',
path: 'guide/ngmodules/singleton-services',
contentPath: 'guide/ngmodules/singleton-services',
},
{
label: 'Lazy-loading feature modules',
path: 'guide/ngmodules/lazy-loading',
contentPath: 'guide/ngmodules/lazy-loading',
},
{
label: 'NgModule API',
path: 'guide/ngmodules/api',
contentPath: 'guide/ngmodules/api',
},
{
label: 'NgModule FAQs',
path: 'guide/ngmodules/faq',
contentPath: 'guide/ngmodules/faq',
},
],
},
],
},
];
const FOOTER_NAVIGATION_DATA: NavigationItem[] = [
+1
View File
@@ -27,6 +27,7 @@ copy_to_directory(
"//adev/src/content/introduction",
"//adev/src/content/introduction/essentials",
"//adev/src/content/reference",
"//adev/src/content/reference/concepts",
"//adev/src/content/reference/configs",
"//adev/src/content/reference/errors",
"//adev/src/content/reference/extended-diagnostics",

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 724 KiB

After

Width:  |  Height:  |  Size: 724 KiB

Before

Width:  |  Height:  |  Size: 745 KiB

After

Width:  |  Height:  |  Size: 745 KiB

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+148
View File
@@ -0,0 +1,148 @@
# Signal inputs
Signal inputs allow values to be bound from parent components.
Those values are exposed using a `Signal` and can change during the lifecycle of your component.
HELPFUL: Signal inputs are currently in [developer preview](/guide/releases#developer-preview).
Angular supports two variants of inputs:
**Optional inputs**
Inputs are optional by default, unless you use `input.required`.
You can specify an explicit initial value, or Angular will use `undefined` implicitly.
**Required inputs**
Required inputs always have a value of the given input type.
They are declared using the `input.required` function.
```typescript
import {Component, input} from '@angular/core';
@Component({...})
export class MyComp {
// optional
firstName = input<string>(); // InputSignal<string|undefined>
age = input(0); // InputSignal<number>
// required
lastName = input.required<string>(); // InputSignal<string>
}
```
An input is automatically recognized by Angular whenever you use the `input` or `input.required` functions as initializer of class members.
## Aliasing an input
Angular uses the class member name as the name of the input.
You can alias inputs to change their public name to be different.
```typescript
class StudentDirective {
age = input(0, {alias: 'studentAge'});
}
```
This allows users to bind to your input using `[studentAge]`, while inside your component you can access the input values using `this.age`.
## Using in templates
Signal inputs are read-only signals.
As with signals declared via `signal()`, you access the current value of the input by calling the input signal.
```html
<p>First name: {{firstName()}}</p>
<p>Last name: {{lastName()}}</p>
```
This access to the value is captured in reactive contexts and can notify active consumers, like Angular itself, whenever the input value changes.
An input signal in practice is a trivial extension of signals that you know from [the signals guide](/guide/signals).
```typescript
export class InputSignal<T> extends Signal<T> { ... }`.
```
## Deriving values
As with signals, you can derive values from inputs using `computed`.
```typescript
import {Component, input, computed} from '@angular/core';
@Component({...})
export class MyComp {
age = input(0);
// age multiplied by two.
ageMultiplied = computed(() => this.age() * 2);
}
```
Computed signals memoize values.
See more details in the [dedicated section for computed](/guide/signals#computed-signals).
## Monitoring changes
With signal inputs, users can leverage the `effect` function.
The function will execute whenever the input changes.
Consider the following example.
The new value is printed to the console whenever the `firstName` input changes.
```typescript
import {input, effect} from '@angular/core';
class MyComp {
firstName = input.required<string>();
constructor() {
effect(() => {
console.log(this.firstName());
});
}
}
```
The `console.log` function is invoked every time the `firstName` input changes.
This will happen as soon as `firstName` is available, and for subsequent changes during the lifetime of `MyComp`.
## Value transforms
You may want to coerce or parse input values without changing the meaning of the input.
Transforms convert the raw value from parent templates to the expected input type.
Transforms should be [pure functions](https://en.wikipedia.org/wiki/Pure_function).
```typescript
class MyComp {
disabled = input(false, {
transform: (value: boolean|string) => typeof value === 'string' ? value === '' : value,
});
}
```
In the example above, you are declaring an input named `disabled` that is accepting values of type `boolean` and `string`.
This is captured by the explicit parameter type of `value` in the `transform` option.
These values are then parsed to a `boolean` with the transform, resulting in booleans.
That way, you are only dealing with `boolean` inside your component when calling `this.disabled()`, while users of your component can pass an empty string as a shorthand to mark your component as disabled.
```html
<my-custom-comp disabled>
```
IMPORTANT:Do not use transforms if they change the meaning of the input, or if they are [impure](https://en.wikipedia.org/wiki/Pure_function#Impure_functions).
Instead, use `computed` for transformations with different meaning, or an `effect` for impure code that should run whenever the input changes.
## Why should we use signal inputs and not `@Input()`?
Signal inputs are a reactive alternative to decorator-based `@Input()`.
In comparison to decorator-based `@Input`, signal inputs provide numerous benefits:
1. Signal inputs are more **type safe**:
<br/>• Required inputs do not require initial values, or tricks to tell TypeScript that an input _always_ has a value.
<br/>• Transforms are automatically checked to match the accepted input values.
2. Signal inputs, when used in templates, will **automatically** mark `OnPush` components as dirty.
3. Values can be easily **derived** whenever an input changes using `computed`.
4. Easier and more local monitoring of inputs using `effect` instead of `ngOnChanges` or setters.
+138
View File
@@ -0,0 +1,138 @@
# Model inputs
**Model inputs** are a special type of input that enable a component to propagate new values
back to another component.
HELPFUL: Model inputs are currently in [developer preview](/guide/releases#developer-preview).
When creating a component, you can define a model input similarly to how you create a standard
input.
```typescript
import {Component, model, input} from '@angular/core';
@Component({...})
export class CustomCheckbox {
// This is a model input.
checked = model(false);
// This is a standard input.
disabled = input(false);
}
```
Both types of input allow someone to bind a value into the property. However, **model inputs allow
the component author to write values into the property**.
In other respects, you can use model inputs the same way you use standard inputs. You can read the
value by calling the signal function, including in reactive contexts like `computed` and `effect`.
```typescript
import {Component, model, input} from '@angular/core';
@Component({
selector: 'custom-checkbox',
template: '<div (click)="toggle()"> ... </div>',
})
export class CustomCheckbox {
checked = model(false);
disabled = input(false);
toggle() {
// While standard inputs are read-only, you can write directly to model inputs.
this.checked.set(!this.checked());
}
}
```
When a component writes a new value into a model input, Angular can propagate the new value back
to the component that is binding a value into that input. This is called **two-way binding** because
values can flow in both directions.
## Two-way binding with signals
You can bind a writable signal to a model input.
```typescript
@Component({
...,
// `checked` is a model input.
// The parenthesis-inside-square-brackets syntax (aka "banana-in-a-box") creates a two-way binding
template: '<custom-checkbox [(checked)]="isAdmin" />',
})
export class UserProfile {
protected isAdmin = signal(false);
}
```
In the above example, the `CustomCheckbox` can write values into its `checked` model input, which
then propagates those values back to the `isAdmin` signal in `UserProfile`. This binding keeps that
values of `checked` and `isAdmin` in sync. Notice that the binding passes the `isAdmin` signal
itself, not the _value_ of the signal.
## Two-way binding with plain properties
You can bind a plain JavaScript property to a model input.
```typescript
@Component({
...,
// `checked` is a model input.
// The parenthesis-inside-square-brackets syntax (aka "banana-in-a-box") creates a two-way binding
template: '<custom-checkbox [(checked)]="isAdmin" />',
})
export class UserProfile {
protected isAdmin = false;
}
```
In the example above, the `CustomCheckbox` can write values into its `checked` model input, which
then propagates those values back to the `isAdmin` property in `UserProfile`. This binding keeps
that values of `checked` and `isAdmin` in sync.
## Implicit `change` events
When you declare a model input in a component or directive, Angular automatically creates a
corresponding [output](/guide/inputs-outputs) for that model. The output's name is the model
input's name suffixed with "Change".
```typescript
@Directive({...})
export class CustomCheckbox {
// This automatically creates an output named "checkedChange".
// Can be subscribed to using `(checkedChange)="handler()"` in the template.
checked = model(false);
}
```
Angular emits this change event whenever you write a new value into the model input by calling
its `set` or `update` methods.
## Customizing model inputs
You can mark a model input as required or provide an alias in the same way as a
[standard input](/guide/signal-inputs).
Model inputs do not support input transforms.
## Differences between `model()` and `input()`
Both `input()` and `model()` functions are ways to define signal-based inputs in Angular, but they
differ in a few ways:
1. `model()` defines **both** an input and an output. The output's name is always the name of the
input suffixed with `Change` to support two-way bindings. It will be up to the consumer of your
directive to decide if they want to use just the input, just the output, or both.
2. `ModelSignal` is a `WritableSignal` which means that its value can be changed from anywhere
using the `set` and `update` methods. When a new value is assigned, the `ModelSignal` will emit
to its output. This is different from `InputSignal` which is read-only and can only be changed
through the template.
3. Model inputs do not support input transforms while signal inputs do.
## When to use model inputs
Use model inputs in components that exist to modify a value based on user interaction.
Custom form controls, such as a date picker or combobox, should use model inputs for their
primary value.
Avoid using model inputs as a convenience to avoid introducing an additional class property for
containing local state.
@@ -0,0 +1,9 @@
load("@npm//@angular/docs:index.bzl", "generate_guides")
generate_guides(
name = "concepts",
srcs = glob([
"*.md",
]),
visibility = ["//adev:__subpackages__"],
)
@@ -0,0 +1,7 @@
# Concepts
<docs-card-container>
<docs-card title="NgModules" link="Learn more" href="guide/ngmodules">
NgModules is a concept that commonly used in architecture v16 and earlier to help configure the injector and the compiler and help organize related things together.
</docs-card>
</docs-card-container>
+1 -1
View File
@@ -1,6 +1,6 @@
# The MIT License
Copyright (c) 2010-2023 Google LLC. https://angular.dev/license
Copyright (c) 2010-2024 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@@ -32,7 +32,7 @@ Note: Look for alerts like this one, which call out steps that may only be for y
The lessons in this tutorial create an Angular app that lists houses for rent and shows the details of individual houses.
This app uses features that are common to many Angular apps.
<img alt="Output of heroes dashboard" src="assets/images/tutorials/first-app/homes-app-landing-page.png">
<img alt="Output of homes landing page" src="assets/images/tutorials/first-app/homes-app-landing-page.png">
## Local development environment
@@ -8,7 +8,7 @@ In this activity you will learn to use constructor-based dependency injection.
<hr>
To inject a service or some other injectable resource into your componenet use the following syntax:
To inject a service or some other injectable resource into your component use the following syntax:
<docs-code language="ts" highlight="[3]">
@Component({...})
+1 -1
View File
@@ -2,7 +2,7 @@
@description
The MIT License
Copyright (c) 2010-2023 Google LLC. https://angular.io/license
Copyright (c) 2010-2024 Google LLC. https://angular.io/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files \(the "Software"\), to deal
+6
View File
@@ -1336,5 +1336,11 @@
"start": "2024-06-13"
},
"name": "JSNation"
},
{
"date": {
"start": "2024-06-27"
},
"name": "dotJS"
}
]
@@ -17,7 +17,7 @@
"uncompressed": {
"runtime": 2734,
"main": 231349,
"polyfills": 33810,
"polyfills": 34169,
"src_app_lazy_lazy_routes_ts": 487
}
},
+1 -1
View File
@@ -20,7 +20,7 @@
"@angular/upgrade": "file:../../dist/packages-dist/upgrade",
"@types/jasmine": "file:../../node_modules/@types/jasmine",
"rxjs": "file:../../node_modules/rxjs",
"typescript": "5.4.1-rc",
"typescript": "5.4.2",
"zone.js": "file:../../dist/zone.js-dist/archive/zone.js.tgz"
},
"scripts": {
+4 -4
View File
@@ -796,10 +796,10 @@ tslib@^2.1.0, tslib@^2.3.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
typescript@5.4.1-rc:
version "5.4.1-rc"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.1-rc.tgz#1ecdd897df1d9ef5bd1f844bad64691ecc23314d"
integrity sha512-gInURzaO0bbfzfQAc3mfcHxh8qev+No4QOFUZHajo9vBgOLaljELJ3wuzyoGo/zHIzMSezdhtrsRdqL6E9SvNA==
typescript@5.4.2:
version "5.4.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.2.tgz#0ae9cebcfae970718474fe0da2c090cad6577372"
integrity sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==
update-browserslist-db@^1.0.13:
version "1.0.13"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "angular-srcs",
"version": "17.3.0-next.1",
"version": "17.3.0",
"private": true,
"description": "Angular - a web framework for modern web apps",
"homepage": "https://github.com/angular/angular",
@@ -141,7 +141,7 @@
"todomvc-common": "^1.0.5",
"tslib": "^2.3.0",
"tslint": "6.1.3",
"typescript": "5.4.1-rc",
"typescript": "5.4.2",
"webtreemap": "^2.0.1",
"ws": "^8.15.0",
"xhr2": "0.2.1",
+4 -1
View File
@@ -100,7 +100,10 @@ export function transferCacheInterceptorFn(
// POST requests are allowed either globally or at request level
(requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) ||
(requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) ||
requestOptions === false || //
// Do not cache request that require authorization
req.headers.has('authorization') ||
req.headers.has('proxy-authorization') ||
requestOptions === false ||
globalOptions.filter?.(req) === false
) {
return next(req);
@@ -248,6 +248,22 @@ describe('TransferCache', () => {
makeRequestAndExpectNone('/test-2?foo=1', 'POST', {transferCache: true});
});
it('should not cache request that requires authorization', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
headers: {Authorization: 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
});
makeRequestAndExpectOne('/test-auth', 'foo');
});
it('should not cache request that requires proxy authorization', async () => {
makeRequestAndExpectOne('/test-auth', 'foo', {
headers: {'Proxy-Authorization': 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
});
makeRequestAndExpectOne('/test-auth', 'foo');
});
describe('caching with global setting', () => {
beforeEach(
withBody('<test-app-http></test-app-http>', () => {
@@ -24,11 +24,10 @@ ts_library(
"//packages/compiler-cli/src/ngtsc/perf",
"//packages/compiler-cli/src/ngtsc/reflection",
"//packages/compiler-cli/src/ngtsc/scope",
#"//packages/compiler-cli/src/ngtsc/shims:api",
"//packages/compiler-cli/src/ngtsc/transform",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
#"//packages/compiler-cli/src/ngtsc/typecheck/diagnostics",
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
"//packages/compiler-cli/src/ngtsc/typecheck/template_semantics/api",
"//packages/compiler-cli/src/ngtsc/util",
"//packages/compiler-cli/src/ngtsc/xi18n",
"@npm//@types/node",
@@ -25,6 +25,7 @@ import {getDiagnosticNode, makeUnknownComponentDeferredImportDiagnostic} from '.
import {AnalysisOutput, CompilationMode, CompileResult, DecoratorHandler, DetectResult, HandlerPrecedence, ResolveResult} from '../../../transform';
import {TypeCheckableDirectiveMeta, TypeCheckContext} from '../../../typecheck/api';
import {ExtendedTemplateChecker} from '../../../typecheck/extended/api';
import {TemplateSemanticsChecker} from '../../../typecheck/template_semantics/api/api';
import {getSourceFile} from '../../../util/src/typescript';
import {Xi18nContext} from '../../../xi18n';
import {combineResolvers, compileDeclareFactory, compileInputTransformFields, compileNgFactoryDefField, compileResults, extractClassDebugInfo, extractClassMetadata, extractSchemas, findAngularDecorator, forwardRefResolver, getDirectiveDiagnostics, getProviderDiagnostics, InjectableClassRegistry, isExpressionForwardReference, readBaseClass, ReferencesRegistry, removeIdentifierReferences, resolveEncapsulationEnumValueLocally, resolveEnumValue, resolveImportedFile, resolveLiteral, resolveProvidersRequiringFactory, ResourceLoader, toFactoryMetadata, tryUnwrapForwardRef, validateHostDirectives, wrapFunctionExpressionsInParens} from '../../common';
@@ -673,6 +674,12 @@ export class ComponentDecoratorHandler implements
return extendedTemplateChecker.getDiagnosticsForComponent(component);
}
templateSemanticsCheck(
component: ts.ClassDeclaration,
templateSemanticsChecker: TemplateSemanticsChecker): ts.Diagnostic[] {
return templateSemanticsChecker.getDiagnosticsForComponent(component);
}
resolve(
node: ClassDeclaration, analysis: Readonly<ComponentAnalysisData>,
symbol: ComponentSymbol): ResolveResult<ComponentResolutionData> {
@@ -25,8 +25,8 @@ import {ClassMember, ReflectionHost} from '../../../reflection';
export interface InitializerApiFunction {
owningModule: '@angular/core'|'@angular/core/rxjs-interop';
functionName: ('input'|'model'|'ɵoutput'|'output'|'outputFromObservable'|'viewChild'|
'viewChildren'|'contentChild'|'contentChildren');
functionName: ('input'|'model'|'output'|'outputFromObservable'|'viewChild'|'viewChildren'|
'contentChild'|'contentChildren');
}
/**
@@ -39,6 +39,8 @@ ts_library(
"//packages/compiler-cli/src/ngtsc/typecheck/diagnostics",
"//packages/compiler-cli/src/ngtsc/typecheck/extended",
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
"//packages/compiler-cli/src/ngtsc/typecheck/template_semantics",
"//packages/compiler-cli/src/ngtsc/typecheck/template_semantics/api",
"//packages/compiler-cli/src/ngtsc/util",
"//packages/compiler-cli/src/ngtsc/xi18n",
"@npm//@types/semver",
@@ -33,6 +33,8 @@ import {TemplateTypeCheckerImpl} from '../../typecheck';
import {OptimizeFor, TemplateTypeChecker, TypeCheckingConfig} from '../../typecheck/api';
import {ALL_DIAGNOSTIC_FACTORIES, ExtendedTemplateCheckerImpl, SUPPORTED_DIAGNOSTIC_NAMES} from '../../typecheck/extended';
import {ExtendedTemplateChecker} from '../../typecheck/extended/api';
import {TemplateSemanticsChecker} from '../../typecheck/template_semantics/api/api';
import {TemplateSemanticsCheckerImpl} from '../../typecheck/template_semantics/src/template_semantics_checker';
import {getSourceFileOrNull, isDtsPath, toUnredirectedSourceFile} from '../../util/src/typescript';
import {Xi18nContext} from '../../xi18n';
import {DiagnosticCategoryLabel, NgCompilerAdapter, NgCompilerOptions} from '../api';
@@ -59,6 +61,7 @@ interface LazyCompilationState {
templateTypeChecker: TemplateTypeChecker;
resourceRegistry: ResourceRegistry;
extendedTemplateChecker: ExtendedTemplateChecker|null;
templateSemanticsChecker: TemplateSemanticsChecker|null;
/**
* Only available in local compilation mode when option `generateExtraImportsInLocalMode` is set.
@@ -442,11 +445,7 @@ export class NgCompiler {
// by running the extended template checking code, which will attempt to
// generate the same TCB.
try {
diagnostics.push(...this.getTemplateDiagnostics());
if (this.options.strictTemplates) {
diagnostics.push(...this.getExtendedTemplateDiagnostics());
}
diagnostics.push(...this.getTemplateDiagnostics(), ...this.runAdditionalChecks());
} catch (err: unknown) {
if (!isFatalDiagnosticError(err)) {
throw err;
@@ -472,11 +471,9 @@ export class NgCompiler {
// by running the extended template checking code, which will attempt to
// generate the same TCB.
try {
diagnostics.push(...this.getTemplateDiagnosticsForFile(file, optimizeFor));
if (this.options.strictTemplates) {
diagnostics.push(...this.getExtendedTemplateDiagnostics(file));
}
diagnostics.push(
...this.getTemplateDiagnosticsForFile(file, optimizeFor),
...this.runAdditionalChecks(file));
} catch (err: unknown) {
if (!isFatalDiagnosticError(err)) {
throw err;
@@ -503,8 +500,12 @@ export class NgCompiler {
try {
diagnostics.push(...ttc.getDiagnosticsForComponent(component));
const extendedTemplateChecker = compilation.extendedTemplateChecker;
if (this.options.strictTemplates && extendedTemplateChecker) {
const {extendedTemplateChecker, templateSemanticsChecker} = compilation;
if (templateSemanticsChecker !== null) {
diagnostics.push(...templateSemanticsChecker.getDiagnosticsForComponent(component));
}
if (this.options.strictTemplates && extendedTemplateChecker !== null) {
diagnostics.push(...extendedTemplateChecker.getDiagnosticsForComponent(component));
}
} catch (err: unknown) {
@@ -968,26 +969,23 @@ export class NgCompiler {
return this.nonTemplateDiagnostics;
}
/**
* Calls the `extendedTemplateCheck` phase of the trait compiler
* @param sf optional parameter to get diagnostics for a certain file
* or all files in the program if `sf` is undefined
* @returns generated extended template diagnostics
*/
private getExtendedTemplateDiagnostics(sf?: ts.SourceFile): ts.Diagnostic[] {
private runAdditionalChecks(sf?: ts.SourceFile): ts.Diagnostic[] {
const diagnostics: ts.Diagnostic[] = [];
const compilation = this.ensureAnalyzed();
const extendedTemplateChecker = compilation.extendedTemplateChecker;
if (!extendedTemplateChecker) {
return [];
}
const {extendedTemplateChecker, templateSemanticsChecker} = compilation;
const files = sf ? [sf] : this.inputProgram.getSourceFiles();
if (sf !== undefined) {
return compilation.traitCompiler.extendedTemplateCheck(sf, extendedTemplateChecker);
}
for (const sf of this.inputProgram.getSourceFiles()) {
diagnostics.push(
...compilation.traitCompiler.extendedTemplateCheck(sf, extendedTemplateChecker));
for (const sf of files) {
if (templateSemanticsChecker !== null) {
diagnostics.push(...compilation.traitCompiler.runAdditionalChecks(sf, (clazz, handler) => {
return handler.templateSemanticsCheck?.(clazz, templateSemanticsChecker) || null;
}));
}
if (this.options.strictTemplates && extendedTemplateChecker !== null) {
diagnostics.push(...compilation.traitCompiler.runAdditionalChecks(sf, (clazz, handler) => {
return handler.extendedTemplateCheck?.(clazz, extendedTemplateChecker) || null;
}));
}
}
return diagnostics;
@@ -1233,6 +1231,10 @@ export class NgCompiler {
templateTypeChecker, checker, ALL_DIAGNOSTIC_FACTORIES, this.options) :
null;
const templateSemanticsChecker = this.constructionDiagnostics.length === 0 ?
new TemplateSemanticsCheckerImpl(templateTypeChecker) :
null;
return {
isCore,
traitCompiler,
@@ -1248,6 +1250,7 @@ export class NgCompiler {
resourceRegistry,
extendedTemplateChecker,
localCompilationExtraImportsTracker,
templateSemanticsChecker,
};
}
}
@@ -21,6 +21,7 @@ ts_library(
"//packages/compiler-cli/src/ngtsc/translator",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
"//packages/compiler-cli/src/ngtsc/typecheck/template_semantics/api",
"//packages/compiler-cli/src/ngtsc/util",
"//packages/compiler-cli/src/ngtsc/xi18n",
"@npm//typescript",
@@ -16,6 +16,7 @@ import {ClassDeclaration, Decorator, ReflectionHost} from '../../reflection';
import {ImportManager} from '../../translator';
import {TypeCheckContext} from '../../typecheck/api';
import {ExtendedTemplateChecker} from '../../typecheck/extended/api';
import {TemplateSemanticsChecker} from '../../typecheck/template_semantics/api/api';
import {Xi18nContext} from '../../xi18n';
/**
@@ -175,6 +176,10 @@ export interface DecoratorHandler<D, A, S extends SemanticSymbol|null, R> {
(component: ts.ClassDeclaration, extendedTemplateChecker: ExtendedTemplateChecker):
ts.Diagnostic[];
templateSemanticsCheck?
(component: ts.ClassDeclaration, templateSemanticsChecker: TemplateSemanticsChecker):
ts.Diagnostic[];
/**
* Generate a description of the field which should be added to the class, including any
* initialization code to be generated.
@@ -17,7 +17,6 @@ import {IndexingContext} from '../../indexer';
import {PerfEvent, PerfRecorder} from '../../perf';
import {ClassDeclaration, DeclarationNode, Decorator, isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ProgramTypeCheckAdapter, TypeCheckContext} from '../../typecheck/api';
import {ExtendedTemplateChecker} from '../../typecheck/extended/api';
import {getSourceFile} from '../../util/src/typescript';
import {Xi18nContext} from '../../xi18n';
@@ -522,8 +521,12 @@ export class TraitCompiler implements ProgramTypeCheckAdapter {
}
}
extendedTemplateCheck(sf: ts.SourceFile, extendedTemplateChecker: ExtendedTemplateChecker):
ts.Diagnostic[] {
runAdditionalChecks(
sf: ts.SourceFile,
check:
(clazz: ts.ClassDeclaration,
handler: DecoratorHandler<unknown, unknown, SemanticSymbol|null, unknown>) =>
ts.Diagnostic[] | null): ts.Diagnostic[] {
if (this.compilationMode === CompilationMode.LOCAL) {
return [];
}
@@ -539,10 +542,10 @@ export class TraitCompiler implements ProgramTypeCheckAdapter {
}
const record = this.classes.get(clazz)!;
for (const trait of record.traits) {
if (trait.handler.extendedTemplateCheck === undefined) {
continue;
const result = check(clazz, trait.handler);
if (result !== null) {
diagnostics.push(...result);
}
diagnostics.push(...trait.handler.extendedTemplateCheck(clazz, extendedTemplateChecker));
}
}
return diagnostics;
@@ -15,7 +15,6 @@ import {NOOP_PERF_RECORDER} from '../../perf';
import {ClassDeclaration, Decorator, isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
import {getDeclaration, makeProgram} from '../../testing';
import {CompilationMode, DetectResult, DtsTransformRegistry, TraitCompiler} from '../../transform';
import {ExtendedTemplateChecker} from '../../typecheck/extended/api';
import {AnalysisOutput, CompileResult, DecoratorHandler, HandlerPrecedence, ResolveResult} from '../src/api';
const fakeSfTypeIdentifier = {
@@ -309,10 +308,6 @@ runInEachFileSystem(() => {
register(): void {}
extendedTemplateCheck() {
return [];
}
updateResources() {}
symbol(): null {
@@ -368,21 +363,6 @@ runInEachFileSystem(() => {
expect(handler.register).toHaveBeenCalled();
});
it('should not call extendedTemplateCheck', () => {
const contents = `
export class Test {}
`;
const handler = new TestDecoratorHandler();
spyOn(handler, 'extendedTemplateCheck');
const {compiler, sourceFile} = setup(contents, [handler], CompilationMode.LOCAL);
compiler.analyzeSync(sourceFile);
compiler.resolve();
compiler.extendedTemplateCheck(sourceFile, {} as ExtendedTemplateChecker);
expect(handler.extendedTemplateCheck).not.toHaveBeenCalled();
});
it('should not call updateResources', () => {
const contents = `
export class Test {}
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, LiteralPrimitive, ParseSourceSpan, PropertyRead, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstTemplate, TmplAstTextAttribute} from '@angular/compiler';
import {AST, LiteralPrimitive, ParseSourceSpan, PropertyRead, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import ts from 'typescript';
import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system';
@@ -213,6 +213,13 @@ export interface TemplateTypeChecker {
*/
invalidateClass(clazz: ts.ClassDeclaration): void;
/**
* Gets the target of a template expression, if possible.
* See `BoundTarget.getExpressionTarget` for more information.
*/
getExpressionTarget(expression: AST, clazz: ts.ClassDeclaration): TmplAstReference|TmplAstVariable
|null;
/**
* Constructs a `ts.Diagnostic` for a given `ParseSourceSpan` within a template.
*/
@@ -10,6 +10,7 @@ ts_library(
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/diagnostics",
"//packages/compiler-cli/src/ngtsc/typecheck",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
"@npm//typescript",
@@ -11,16 +11,9 @@ import ts from 'typescript';
import {ErrorCode, ExtendedTemplateDiagnosticName} from '../../../../diagnostics';
import {NgTemplateDiagnostic, SymbolKind} from '../../../api';
import {isSignalReference} from '../../../src/symbol_util';
import {TemplateCheckFactory, TemplateCheckWithVisitor, TemplateContext} from '../../api';
/** Names of known signal functions. */
const SIGNAL_FNS = new Set([
'WritableSignal',
'Signal',
'InputSignal',
'InputSignalWithTransform',
'ModelSignal',
]);
/** Names of known signal instance properties. */
const SIGNAL_INSTANCE_PROPERTIES = new Set(['set', 'update', 'asReadonly']);
@@ -50,18 +43,6 @@ class InterpolatedSignalCheck extends
}
}
function isSignal(symbol: ts.Symbol|undefined): boolean {
const declarations = symbol?.getDeclarations();
return declarations !== undefined && declarations.some(decl => {
const fileName = decl.getSourceFile().fileName;
return (ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl)) &&
SIGNAL_FNS.has(decl.name.text) &&
(fileName.includes('@angular/core') || fileName.includes('angular2/rc/packages/core'));
});
}
function isFunctionInstanceProperty(name: string): boolean {
return FUNCTION_INSTANCE_PROPERTIES.has(name);
}
@@ -76,8 +57,7 @@ function buildDiagnosticForSignal(
Array<NgTemplateDiagnostic<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>> {
// check for `{{ mySignal }}`
const symbol = ctx.templateTypeChecker.getSymbolOfNode(node, component);
if (symbol?.kind === SymbolKind.Expression &&
(isSignal(symbol.tsType.symbol) || isSignal(symbol.tsType.aliasSymbol))) {
if (symbol !== null && symbol.kind === SymbolKind.Expression && isSignalReference(symbol)) {
const templateMapping =
ctx.templateTypeChecker.getTemplateMappingAtTcbLocation(symbol.tcbLocation)!;
const errorString = `${node.name} is a function and should be invoked: ${node.name}()`;
@@ -92,8 +72,8 @@ function buildDiagnosticForSignal(
// `{{ mySignal.asReadonly }}` as these are the names of instance properties of Signal
const symbolOfReceiver = ctx.templateTypeChecker.getSymbolOfNode(node.receiver, component);
if ((isFunctionInstanceProperty(node.name) || isSignalInstanceProperty(node.name)) &&
symbolOfReceiver?.kind === SymbolKind.Expression &&
(isSignal(symbolOfReceiver.tsType.symbol) || isSignal(symbolOfReceiver.tsType.aliasSymbol))) {
symbolOfReceiver !== null && symbolOfReceiver.kind === SymbolKind.Expression &&
isSignalReference(symbolOfReceiver)) {
const templateMapping =
ctx.templateTypeChecker.getTemplateMappingAtTcbLocation(symbolOfReceiver.tcbLocation)!;
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, CssSelector, DomElementSchemaRegistry, ExternalExpr, LiteralPrimitive, ParseSourceSpan, PropertyRead, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstTemplate, TmplAstTextAttribute, WrappedNodeExpr} from '@angular/compiler';
import {AST, CssSelector, DomElementSchemaRegistry, ExternalExpr, LiteralPrimitive, ParseSourceSpan, PropertyRead, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, WrappedNodeExpr} from '@angular/compiler';
import ts from 'typescript';
import {ErrorCode, ngErrorCode} from '../../diagnostics';
@@ -345,6 +345,12 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
this.isComplete = false;
}
getExpressionTarget(expression: AST, clazz: ts.ClassDeclaration): TmplAstReference|TmplAstVariable
|null {
return this.getLatestComponentState(clazz).data?.boundTarget.getExpressionTarget(expression) ||
null;
}
makeTemplateDiagnostic<T extends ErrorCode>(
clazz: ts.ClassDeclaration, sourceSpan: ParseSourceSpan, category: ts.DiagnosticCategory,
errorCode: T, message: string, relatedInformation?: {
@@ -70,9 +70,6 @@ export interface OutOfBandDiagnosticRecorder {
*/
deferredComponentUsedEagerly(templateId: TemplateId, element: TmplAstElement): void;
illegalAssignmentToTemplateVar(
templateId: TemplateId, assignment: PropertyWrite, target: TmplAstVariable): void;
/**
* Reports a duplicate declaration of a template variable.
*
@@ -217,27 +214,6 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor
ngErrorCode(ErrorCode.DEFERRED_DIRECTIVE_USED_EAGERLY), errorMsg));
}
illegalAssignmentToTemplateVar(
templateId: TemplateId, assignment: PropertyWrite, target: TmplAstVariable): void {
const mapping = this.resolver.getSourceMapping(templateId);
const errorMsg = `Cannot use variable '${
assignment
.name}' as the left-hand side of an assignment expression. Template variables are read-only.`;
const sourceSpan = this.resolver.toParseSourceSpan(templateId, assignment.sourceSpan);
if (sourceSpan === null) {
throw new Error(`Assertion failure: no SourceLocation found for property binding.`);
}
this._diagnostics.push(makeTemplateDiagnostic(
templateId, mapping, sourceSpan, ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.WRITE_TO_READ_ONLY_VARIABLE), errorMsg, [{
text: `The variable ${assignment.name} is declared here.`,
start: target.valueSpan?.start.offset || target.sourceSpan.start.offset,
end: target.valueSpan?.end.offset || target.sourceSpan.end.offset,
sourceFile: mapping.node.getSourceFile(),
}]));
}
duplicateTemplateVar(
templateId: TemplateId, variable: TmplAstVariable, firstDecl: TmplAstVariable): void {
const mapping = this.resolver.getSourceMapping(templateId);
@@ -0,0 +1,42 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import ts from 'typescript';
import {Symbol, SymbolKind} from '../api';
/** Names of known signal functions. */
const SIGNAL_FNS = new Set([
'WritableSignal',
'Signal',
'InputSignal',
'InputSignalWithTransform',
'ModelSignal',
]);
/** Returns whether a symbol is a reference to a signal. */
export function isSignalReference(symbol: Symbol): boolean {
return (symbol.kind === SymbolKind.Expression || symbol.kind === SymbolKind.Variable) &&
// Note that `tsType.symbol` isn't optional in the typings,
// but it appears that it can be undefined at runtime.
(symbol.tsType.symbol !== undefined && isSignalSymbol(symbol.tsType.symbol) ||
(symbol.tsType.aliasSymbol !== undefined && isSignalSymbol(symbol.tsType.aliasSymbol)));
}
/** Checks whether a symbol points to a signal. */
function isSignalSymbol(symbol: ts.Symbol): boolean {
const declarations = symbol.getDeclarations();
return declarations !== undefined && declarations.some(decl => {
const fileName = decl.getSourceFile().fileName;
return (ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl)) &&
SIGNAL_FNS.has(decl.name.text) &&
(fileName.includes('@angular/core') || fileName.includes('angular2/rc/packages/core'));
});
}
@@ -1,44 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AST, BoundTarget, ImplicitReceiver, PropertyWrite, RecursiveAstVisitor, TmplAstVariable} from '@angular/compiler';
import {TemplateId} from '../api';
import {OutOfBandDiagnosticRecorder} from './oob';
/**
* Visits a template and records any semantic errors within its expressions.
*/
export class ExpressionSemanticVisitor extends RecursiveAstVisitor {
constructor(
private templateId: TemplateId, private boundTarget: BoundTarget<any>,
private oob: OutOfBandDiagnosticRecorder) {
super();
}
override visitPropertyWrite(ast: PropertyWrite, context: any): void {
super.visitPropertyWrite(ast, context);
if (!(ast.receiver instanceof ImplicitReceiver)) {
return;
}
const target = this.boundTarget.getExpressionTarget(ast);
if (target instanceof TmplAstVariable) {
// Template variables are read-only.
this.oob.illegalAssignmentToTemplateVar(this.templateId, ast, target);
}
}
static visit(
ast: AST, id: TemplateId, boundTarget: BoundTarget<any>,
oob: OutOfBandDiagnosticRecorder): void {
ast.visit(new ExpressionSemanticVisitor(id, boundTarget, oob));
}
}
@@ -20,7 +20,6 @@ import {DomSchemaChecker} from './dom';
import {Environment} from './environment';
import {astToTypescript, NULL_AS_ANY} from './expression';
import {OutOfBandDiagnosticRecorder} from './oob';
import {ExpressionSemanticVisitor} from './template_semantics';
import {tsCallMethod, tsCastToAny, tsCreateElement, tsCreateTypeQueryForCoercedInput, tsCreateVariable, tsDeclareVariable} from './ts_util';
import {requiresInlineTypeCtor} from './type_constructor';
import {TypeParameterEmitter} from './type_parameter_emitter';
@@ -1214,9 +1213,6 @@ export class TcbDirectiveOutputsOp extends TcbOp {
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
this.scope.addStatement(ts.factory.createExpressionStatement(handler));
}
ExpressionSemanticVisitor.visit(
output.handler, this.tcb.id, this.tcb.boundTarget, this.tcb.oobRecorder);
}
return null;
@@ -1292,9 +1288,6 @@ class TcbUnclaimedOutputsOp extends TcbOp {
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
this.scope.addStatement(ts.factory.createExpressionStatement(handler));
}
ExpressionSemanticVisitor.visit(
output.handler, this.tcb.id, this.tcb.boundTarget, this.tcb.oobRecorder);
}
return null;
@@ -0,0 +1,18 @@
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "template_semantics",
srcs = glob(
["**/*.ts"],
),
visibility = ["//packages/compiler-cli/src/ngtsc:__subpackages__"],
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/core:api",
"//packages/compiler-cli/src/ngtsc/diagnostics",
"//packages/compiler-cli/src/ngtsc/typecheck",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
"//packages/compiler-cli/src/ngtsc/typecheck/template_semantics/api",
"@npm//typescript",
],
)
@@ -0,0 +1,16 @@
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "api",
srcs = glob(
["**/*.ts"],
),
visibility = ["//packages/compiler-cli/src/ngtsc:__subpackages__"],
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/core:api",
"//packages/compiler-cli/src/ngtsc/diagnostics",
"//packages/compiler-cli/src/ngtsc/typecheck/api",
"@npm//typescript",
],
)
@@ -0,0 +1,21 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import ts from 'typescript';
import {TemplateDiagnostic} from '../../api';
/**
* Interface to generate diagnostics related to the semantics of a component's template.
*/
export interface TemplateSemanticsChecker {
/**
* Run `TemplateSemanticsChecker`s for a component and return the generated `ts.Diagnostic`s.
*/
getDiagnosticsForComponent(component: ts.ClassDeclaration): TemplateDiagnostic[];
}
@@ -0,0 +1,121 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AST, ASTWithSource, ImplicitReceiver, ParsedEventType, PropertyRead, PropertyWrite, RecursiveAstVisitor, TmplAstBoundEvent, TmplAstNode, TmplAstRecursiveVisitor, TmplAstVariable} from '@angular/compiler';
import ts from 'typescript';
import {ErrorCode, ngErrorCode} from '../../../diagnostics';
import {TemplateDiagnostic, TemplateTypeChecker} from '../../api';
import {isSignalReference} from '../../src/symbol_util';
import {TemplateSemanticsChecker} from '../api/api';
export class TemplateSemanticsCheckerImpl implements TemplateSemanticsChecker {
constructor(private templateTypeChecker: TemplateTypeChecker) {}
getDiagnosticsForComponent(component: ts.ClassDeclaration): TemplateDiagnostic[] {
const template = this.templateTypeChecker.getTemplate(component);
return template !== null ?
TemplateSemanticsVisitor.visit(template, component, this.templateTypeChecker) :
[];
}
}
/** Visitor that verifies the semantics of a template. */
class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor {
private constructor(private expressionVisitor: ExpressionsSemanticsVisitor) {
super();
}
static visit(
nodes: TmplAstNode[], component: ts.ClassDeclaration,
templateTypeChecker: TemplateTypeChecker) {
const diagnostics: TemplateDiagnostic[] = [];
const expressionVisitor =
new ExpressionsSemanticsVisitor(templateTypeChecker, component, diagnostics);
const templateVisitor = new TemplateSemanticsVisitor(expressionVisitor);
nodes.forEach(node => node.visit(templateVisitor));
return diagnostics;
}
override visitBoundEvent(event: TmplAstBoundEvent): void {
super.visitBoundEvent(event);
event.handler.visit(this.expressionVisitor, event);
}
}
/** Visitor that verifies the semantics of the expressions within a template. */
class ExpressionsSemanticsVisitor extends RecursiveAstVisitor {
constructor(
private templateTypeChecker: TemplateTypeChecker, private component: ts.ClassDeclaration,
private diagnostics: TemplateDiagnostic[]) {
super();
}
override visitPropertyWrite(ast: PropertyWrite, context: TmplAstNode): void {
super.visitPropertyWrite(ast, context);
this.checkForIllegalWriteInEventBinding(ast, context);
}
override visitPropertyRead(ast: PropertyRead, context: TmplAstNode) {
super.visitPropertyRead(ast, context);
this.checkForIllegalWriteInTwoWayBinding(ast, context);
}
private checkForIllegalWriteInEventBinding(ast: PropertyWrite, context: TmplAstNode) {
if (!(context instanceof TmplAstBoundEvent) || !(ast.receiver instanceof ImplicitReceiver)) {
return;
}
const target = this.templateTypeChecker.getExpressionTarget(ast, this.component);
if (target instanceof TmplAstVariable) {
const errorMessage = `Cannot use variable '${
target
.name}' as the left-hand side of an assignment expression. Template variables are read-only.`;
this.diagnostics.push(this.makeIllegalTemplateVarDiagnostic(target, context, errorMessage));
}
}
private checkForIllegalWriteInTwoWayBinding(ast: PropertyRead, context: TmplAstNode) {
// Only check top-level property reads inside two-way bindings for illegal assignments.
if (!(context instanceof TmplAstBoundEvent) || context.type !== ParsedEventType.TwoWay ||
!(ast.receiver instanceof ImplicitReceiver) ||
ast !== unwrapAstWithSource(context.handler)) {
return;
}
const target = this.templateTypeChecker.getExpressionTarget(ast, this.component);
if (!(target instanceof TmplAstVariable)) {
return;
}
// Two-way bindings to template variables are only allowed if the variables are signals.
const symbol = this.templateTypeChecker.getSymbolOfNode(target, this.component);
if (symbol !== null && !isSignalReference(symbol)) {
const errorMessage = `Cannot use a non-signal variable '${
target.name}' in a two-way binding expression. Template variables are read-only.`;
this.diagnostics.push(this.makeIllegalTemplateVarDiagnostic(target, context, errorMessage));
}
}
private makeIllegalTemplateVarDiagnostic(
target: TmplAstVariable, expressionNode: TmplAstBoundEvent,
errorMessage: string): TemplateDiagnostic {
return this.templateTypeChecker.makeTemplateDiagnostic(
this.component, expressionNode.handlerSpan, ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.WRITE_TO_READ_ONLY_VARIABLE), errorMessage, [{
text: `The variable ${target.name} is declared here.`,
start: target.valueSpan?.start.offset || target.sourceSpan.start.offset,
end: target.valueSpan?.end.offset || target.sourceSpan.end.offset,
sourceFile: this.component.getSourceFile(),
}]);
}
}
function unwrapAstWithSource(ast: AST): AST {
return ast instanceof ASTWithSource ? ast.ast : ast;
}
@@ -859,7 +859,6 @@ export class NoopOobRecorder implements OutOfBandDiagnosticRecorder {
missingPipe(): void {}
deferredPipeUsedEagerly(templateId: TemplateId, ast: BindingPipe): void {}
deferredComponentUsedEagerly(templateId: TemplateId, element: TmplAstElement): void {}
illegalAssignmentToTemplateVar(): void {}
duplicateTemplateVar(): void {}
requiresInlineTcb(): void {}
requiresInlineTypeConstructors(): void {}
@@ -917,3 +917,61 @@ export declare class App {
static ɵcmp: i0.ɵɵComponentDeclaration<App, "ng-component", never, {}, {}, never, never, true, never>;
}
/****************************************************************************************************
* PARTIAL FILE: two_way_binding_to_signal_loop_variable.js
****************************************************************************************************/
import { Component, Directive, model, signal } from '@angular/core';
import * as i0 from "@angular/core";
export class NgModelDirective {
constructor() {
this.ngModel = model.required();
}
}
NgModelDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
NgModelDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "0.0.0-PLACEHOLDER", type: NgModelDirective, isStandalone: true, selector: "[ngModel]", inputs: { ngModel: { classPropertyName: "ngModel", publicName: "ngModel", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { ngModel: "ngModelChange" }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, decorators: [{
type: Directive,
args: [{
selector: '[ngModel]',
standalone: true,
}]
}] });
export class TestCmp {
constructor() {
this.names = [signal('Angular')];
}
}
TestCmp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, deps: [], target: i0.ɵɵFactoryTarget.Component });
TestCmp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmp, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
@for (name of names; track $index) {
<input [(ngModel)]="name" />
}
`, isInline: true, dependencies: [{ kind: "directive", type: NgModelDirective, selector: "[ngModel]", inputs: ["ngModel"], outputs: ["ngModelChange"] }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, decorators: [{
type: Component,
args: [{
template: `
@for (name of names; track $index) {
<input [(ngModel)]="name" />
}
`,
standalone: true,
imports: [NgModelDirective],
}]
}] });
/****************************************************************************************************
* PARTIAL FILE: two_way_binding_to_signal_loop_variable.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class NgModelDirective {
ngModel: import("@angular/core").ModelSignal<string>;
static ɵfac: i0.ɵɵFactoryDeclaration<NgModelDirective, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<NgModelDirective, "[ngModel]", never, { "ngModel": { "alias": "ngModel"; "required": true; "isSignal": true; }; }, { "ngModel": "ngModelChange"; }, never, never, true, never>;
}
export declare class TestCmp {
names: import("@angular/core").WritableSignal<string>[];
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmp, "ng-component", never, {}, {}, never, never, true, never>;
}
@@ -355,6 +355,23 @@
"failureMessage": "Incorrect template"
}
]
},
{
"description": "should generate a two-way binding to a @for loop variable that is a signal",
"inputFiles": [
"two_way_binding_to_signal_loop_variable.ts"
],
"expectations": [
{
"files": [
{
"expected": "two_way_binding_to_signal_loop_variable_template.js",
"generated": "two_way_binding_to_signal_loop_variable.js"
}
],
"failureMessage": "Incorrect template"
}
]
}
]
}
@@ -0,0 +1,22 @@
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[ngModel]',
standalone: true,
})
export class NgModelDirective {
ngModel = model.required<string>();
}
@Component({
template: `
@for (name of names; track $index) {
<input [(ngModel)]="name" />
}
`,
standalone: true,
imports: [NgModelDirective],
})
export class TestCmp {
names = [signal('Angular')];
}
@@ -0,0 +1,27 @@
function TestCmp_For_1_Template(rf, ctx) {
if (rf & 1) {
const $_r1$ = $r3$.ɵɵgetCurrentView();
$r3$.ɵɵelementStart(0, "input", 0);
$r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_For_1_Template_input_ngModelChange_0_listener($event) {
const $name_r2$ = $r3$.ɵɵrestoreView($_r1$).$implicit;
$r3$.ɵɵtwoWayBindingSet($name_r2$, $event);
return $r3$.ɵɵresetView($event);
});
$r3$.ɵɵelementEnd();
}
if (rf & 2) {
const $name_r2$ = ctx.$implicit;
$r3$.ɵɵtwoWayProperty("ngModel", $name_r2$);
}
}
…
function TestCmp_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵrepeaterCreate(0, TestCmp_For_1_Template, 1, 1, "input", null, $r3$.ɵɵrepeaterTrackByIndex);
}
if (rf & 2) {
$r3$.ɵɵrepeater(ctx.names);
}
}
@@ -199,7 +199,6 @@ runInEachFileSystem(() => {
.toContain(`inputs: { data: [i0.ɵɵInputFlags.SignalBased, "data"] }`);
});
// TODO(crisbeto): we may not want to support this combination. Will discuss with the team.
it('should report mixed two-way binding with a signal input', () => {
env.write('test.ts', `
import {Component, Directive, input, Output, EventEmitter} from '@angular/core';
@@ -4868,6 +4868,70 @@ suppress
]);
});
it('should not be able to write to loop template variables in a two-way binding', () => {
env.write('test.ts', `
import {Component, Directive, Input, Output, EventEmitter} from '@angular/core';
@Directive({
selector: '[twoWayDir]',
standalone: true
})
export class TwoWayDir {
@Input() value: number = 0;
@Output() valueChanges: EventEmitter<number> = new EventEmitter();
}
@Component({
template: \`
@for (item of items; track item) {
<button twoWayDir [(value)]="$index"></button>
}
\`,
standalone: true,
imports: [TwoWayDir]
})
export class Main {
items = [];
}
`);
const diags = env.driveDiagnostics();
expect(diags.map(d => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
`Cannot use a non-signal variable '$index' in a two-way binding expression. Template variables are read-only.`,
]);
});
it('should allow writes to signal-based template variables in two-way bindings', () => {
env.write('test.ts', `
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
@Directive({
selector: '[twoWayDir]',
standalone: true
})
export class TwoWayDir {
@Input() value: number = 0;
@Output() valueChanges: EventEmitter<number> = new EventEmitter();
}
@Component({
template: \`
@for (current of signals; track current) {
<button twoWayDir [(value)]="current"></button>
}
\`,
standalone: true,
imports: [TwoWayDir]
})
export class Main {
signals = [signal(1)];
}
`);
const diags = env.driveDiagnostics();
expect(diags).toEqual([]);
});
it('should check the track expression of a for loop block', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ASTWithSource} from '../expression_parser/ast';
import {ASTWithSource, EmptyExpr} from '../expression_parser/ast';
import * as html from '../ml_parser/ast';
import {ParseError, ParseSourceSpan} from '../parse_util';
import {BindingParser} from '../template_parser/binding_parser';
@@ -256,6 +256,9 @@ function parseForLoopParameters(
new ParseError(param.sourceSpan, '@for loop can only have one "track" expression'));
} else {
const expression = parseBlockParameterToBinding(param, bindingParser, trackMatch[1]);
if (expression.ast instanceof EmptyExpr) {
errors.push(new ParseError(param.sourceSpan, '@for loop must have a "track" expression'));
}
const keywordSpan = new ParseSourceSpan(
param.sourceSpan.start, param.sourceSpan.start.moveBy('track'.length));
result.trackBy = {expression, keywordSpan};
@@ -32,12 +32,24 @@ export function transformTwoWayBindingSet(job: CompilationJob): void {
}
}
function wrapSetOperation(target: o.ReadPropExpr|o.ReadKeyExpr, value: o.Expression): o.Expression {
function wrapSetOperation(
target: o.ReadPropExpr|o.ReadKeyExpr|ir.ReadVariableExpr, value: o.Expression): o.Expression {
// ASSUMPTION: here we're assuming that `ReadVariableExpr` will be a reference
// to a local template variable. This appears to be the case at the time of writing.
// If the expression is targeting a variable read, we only emit the `twoWayBindingSet` since
// the fallback would be attempting to write into a constant. Invalid usages will be flagged
// during template type checking.
if (target instanceof ir.ReadVariableExpr) {
return ng.twoWayBindingSet(target, value);
}
return ng.twoWayBindingSet(target, value).or(target.set(value));
}
function isReadExpression(value: unknown): value is o.ReadPropExpr|o.ReadKeyExpr {
return value instanceof o.ReadPropExpr || value instanceof o.ReadKeyExpr;
function isReadExpression(value: unknown): value is o.ReadPropExpr|o.ReadKeyExpr|
ir.ReadVariableExpr {
return value instanceof o.ReadPropExpr || value instanceof o.ReadKeyExpr ||
value instanceof ir.ReadVariableExpr;
}
function wrapAction(target: o.Expression, value: o.Expression): o.Expression {
@@ -1520,6 +1520,11 @@ describe('R3 template transform', () => {
@case {case}
}
`)).toThrowError(/@case block must have exactly one parameter/);
expect(() => parse(`
@switch (cond) {
@case ( ) {case}
}
`)).toThrowError(/@case block must have exactly one parameter/);
});
it('should report if a case has more than one parameter', () => {
@@ -1711,6 +1716,8 @@ describe('R3 template transform', () => {
it('should report if for loop does not have a tracking expression', () => {
expect(() => parse(`@for (a of b) {hello}`))
.toThrowError(/@for loop must have a "track" expression/);
expect(() => parse(`@for (a of b; track ) {hello}`))
.toThrowError(/@for loop must have a "track" expression/);
});
it('should report mismatching optional parentheses around for loop expression', () => {
@@ -1932,6 +1939,9 @@ describe('R3 template transform', () => {
expect(() => parse(`
@if {hello}
`)).toThrowError(/Conditional block does not have an expression/);
expect(() => parse(`
@if ( ) {hello}
`)).toThrowError(/Conditional block does not have an expression/);
});
it('should report an unknown parameter in an if block', () => {
@@ -21,7 +21,6 @@ import {EnvironmentInjector} from '../di/r3_injector';
import {ErrorHandler, INTERNAL_APPLICATION_ERROR_HANDLER} from '../error_handler';
import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors';
import {Type} from '../interface/type';
import {isG3} from '../is_internal';
import {ComponentFactory, ComponentRef} from '../linker/component_factory';
import {ComponentFactoryResolver} from '../linker/component_factory_resolver';
import {NgModuleRef} from '../linker/ng_module_factory';
@@ -492,6 +491,11 @@ export class ApplicationRef {
* detection pass during which all change detection must complete.
*/
tick(): void {
this._tick(true);
}
/** @internal */
_tick(refreshViews: boolean): void {
(typeof ngDevMode === 'undefined' || ngDevMode) && this.warnIfDestroyed();
if (this._runningTick) {
throw new RuntimeError(
@@ -503,7 +507,7 @@ export class ApplicationRef {
try {
this._runningTick = true;
this.detectChangesInAttachedViews();
this.detectChangesInAttachedViews(refreshViews);
if ((typeof ngDevMode === 'undefined' || ngDevMode)) {
for (let view of this._views) {
@@ -520,7 +524,7 @@ export class ApplicationRef {
}
}
private detectChangesInAttachedViews() {
private detectChangesInAttachedViews(refreshViews: boolean) {
let runs = 0;
const afterRenderEffectManager = this.afterRenderEffectManager;
while (true) {
@@ -532,10 +536,12 @@ export class ApplicationRef {
'Ensure afterRender or queueStateUpdate hooks are not continuously causing updates.');
}
const isFirstPass = runs === 0;
this.beforeRender.next(isFirstPass);
for (let {_lView, notifyErrorHandler} of this._views) {
detectChangesInViewIfRequired(_lView, isFirstPass, notifyErrorHandler);
if (refreshViews) {
const isFirstPass = runs === 0;
this.beforeRender.next(isFirstPass);
for (let {_lView, notifyErrorHandler} of this._views) {
detectChangesInViewIfRequired(_lView, isFirstPass, notifyErrorHandler);
}
}
runs++;
@@ -712,9 +718,7 @@ export function detectChangesInViewIfRequired(
}
function shouldRecheckView(view: LView): boolean {
return requiresRefreshOrTraversal(view) ||
// TODO(atscott): Remove isG3 check and make this a breaking change for v18
(isG3 && !!(view[FLAGS] & LViewFlags.Dirty));
return requiresRefreshOrTraversal(view);
}
function detectChangesInView(lView: LView, notifyErrorHandler: boolean, isFirstPass: boolean) {
@@ -6,17 +6,21 @@
* found in the LICENSE file at https://angular.io/license
*/
import {assertInInjectionContext} from '../../di';
import {createInputSignal, InputOptions, InputOptionsWithoutTransform, InputOptionsWithTransform, InputSignal, InputSignalWithTransform} from './input_signal';
import {REQUIRED_UNSET_VALUE} from './input_signal_node';
export function inputFunction<ReadT, WriteT>(
initialValue?: ReadT,
opts?: InputOptions<ReadT, WriteT>): InputSignalWithTransform<ReadT|undefined, WriteT> {
ngDevMode && assertInInjectionContext(input);
return createInputSignal(initialValue, opts);
}
export function inputRequiredFunction<ReadT, WriteT>(opts?: InputOptions<ReadT, WriteT>):
InputSignalWithTransform<ReadT, WriteT> {
ngDevMode && assertInInjectionContext(input);
return createInputSignal(REQUIRED_UNSET_VALUE as never, opts);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export interface OutputOptions {
}
/**
* The `outputs` function allows declaration of outputs in directives and
* The `output` function allows declaration of outputs in directives and
* components.
*
* Initializes an output that can emit values to consumers of your
@@ -9,6 +9,7 @@
import {setActiveConsumer} from '@angular/core/primitives/signals';
import {inject} from '../../di/injector_compatibility';
import {ErrorHandler} from '../../error_handler';
import {RuntimeError, RuntimeErrorCode} from '../../errors';
import {DestroyRef} from '../../linker/destroy_ref';
@@ -30,6 +31,7 @@ import {OutputRef, OutputRefSubscription} from './output_ref';
export class OutputEmitterRef<T> implements OutputRef<T> {
private destroyed = false;
private listeners: Array<(value: T) => void>|null = null;
private errorHandler = inject(ErrorHandler, {optional: true});
/** @internal */
destroyRef: DestroyRef = inject(DestroyRef);
@@ -73,10 +75,19 @@ export class OutputEmitterRef<T> implements OutputRef<T> {
'The owning directive/component is destroyed.');
}
if (this.listeners === null) {
return;
}
const previousConsumer = setActiveConsumer(null);
try {
// TODO: Run every listener using `try/catch`.
this.listeners?.forEach(fn => fn(value));
for (const listenerFn of this.listeners) {
try {
listenerFn(value);
} catch (err: unknown) {
this.errorHandler?.handleError(err);
}
}
} finally {
setActiveConsumer(previousConsumer);
}
+6
View File
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {assertInInjectionContext} from '../di';
import {ProviderToken} from '../di/provider_token';
import {createMultiResultQuerySignalFn, createSingleResultOptionalQuerySignalFn, createSingleResultRequiredQuerySignalFn} from '../render3/query_reactive';
import {Signal} from '../render3/reactivity/api';
@@ -13,11 +14,13 @@ import {Signal} from '../render3/reactivity/api';
function viewChildFn<LocatorT, ReadT>(
locator: ProviderToken<LocatorT>|string,
opts?: {read?: ProviderToken<ReadT>}): Signal<ReadT|undefined> {
ngDevMode && assertInInjectionContext(viewChild);
return createSingleResultOptionalQuerySignalFn<ReadT>();
}
function viewChildRequiredFn<LocatorT, ReadT>(
locator: ProviderToken<LocatorT>|string, opts?: {read?: ProviderToken<ReadT>}): Signal<ReadT> {
ngDevMode && assertInInjectionContext(viewChild);
return createSingleResultRequiredQuerySignalFn<ReadT>();
}
@@ -108,18 +111,21 @@ export function viewChildren<LocatorT, ReadT>(
export function viewChildren<LocatorT, ReadT>(
locator: ProviderToken<LocatorT>|string,
opts?: {read?: ProviderToken<ReadT>}): Signal<ReadonlyArray<ReadT>> {
ngDevMode && assertInInjectionContext(viewChildren);
return createMultiResultQuerySignalFn<ReadT>();
}
export function contentChildFn<LocatorT, ReadT>(
locator: ProviderToken<LocatorT>|string,
opts?: {descendants?: boolean, read?: ProviderToken<ReadT>}): Signal<ReadT|undefined> {
ngDevMode && assertInInjectionContext(contentChild);
return createSingleResultOptionalQuerySignalFn<ReadT>();
}
function contentChildRequiredFn<LocatorT, ReadT>(
locator: ProviderToken<LocatorT>|string,
opts?: {descendants?: boolean, read?: ProviderToken<ReadT>}): Signal<ReadT> {
ngDevMode && assertInInjectionContext(contentChildren);
return createSingleResultRequiredQuerySignalFn<ReadT>();
}
@@ -6,9 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
export const enum NotificationType {
RefreshViews,
AfterRenderHooks,
}
/**
* Injectable that is notified when an `LView` is made aware of changes to application state.
*/
export abstract class ChangeDetectionScheduler {
abstract notify(): void;
abstract notify(source?: NotificationType): void;
}
@@ -12,16 +12,22 @@ import {PendingTasks} from '../../pending_tasks';
import {global} from '../../util/global';
import {NgZone, NoopNgZone} from '../../zone/ng_zone';
import {ChangeDetectionScheduler} from './zoneless_scheduling';
import {ChangeDetectionScheduler, NotificationType} from './zoneless_scheduling';
@Injectable({providedIn: 'root'})
class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private appRef = inject(ApplicationRef);
private taskService = inject(PendingTasks);
private pendingRenderTaskId: number|null = null;
private shouldRefreshViews = false;
notify(): void {
if (this.pendingRenderTaskId !== null) return;
notify(type = NotificationType.RefreshViews): void {
// When the only source of notification is an afterRender hook will skip straight to the hooks
// rather than refreshing views in ApplicationRef.tick
this.shouldRefreshViews ||= type === NotificationType.RefreshViews;
if (this.pendingRenderTaskId !== null) {
return;
}
this.pendingRenderTaskId = this.taskService.add();
this.raceTimeoutAndRequestAnimationFrame();
@@ -63,9 +69,10 @@ class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private tick() {
try {
if (!this.appRef.destroyed) {
this.appRef.tick();
this.appRef._tick(this.shouldRefreshViews);
}
} finally {
this.shouldRefreshViews = false;
// If this is the last task, the service will synchronously emit a stable notification. If
// there is a subscriber that then acts in a way that tries to notify the scheduler again,
// we need to be able to respond to schedule a new change detection. Therefore, we should
+1 -2
View File
@@ -25,13 +25,12 @@ export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope';
export {XSS_SECURITY_URL as ɵXSS_SECURITY_URL} from './error_details_base_url';
export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError, RuntimeErrorCode as ɵRuntimeErrorCode} from './errors';
export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate';
export {withDomHydration as ɵwithDomHydration} from './hydration/api';
export {withDomHydration as ɵwithDomHydration, withI18nHydration as ɵwithI18nHydration} from './hydration/api';
export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens';
export {HydratedNode as ɵHydratedNode, HydrationInfo as ɵHydrationInfo, readHydrationInfo as ɵreadHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
export {Writable as ɵWritable} from './interface/type';
export {isG3 as ɵisG3} from './is_internal';
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';
export {clearResolutionOfComponentResourcesQueue as ɵclearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution as ɵisComponentDefPendingResolution, resolveComponentResources as ɵresolveComponentResources, restoreComponentResolutionQueue as ɵrestoreComponentResolutionQueue} from './metadata/resource_loading';
export {PendingTasks as ɵPendingTasks} from './pending_tasks';
+9 -2
View File
@@ -8,9 +8,10 @@
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {InjectorProfilerContext, setInjectorProfilerContext} from '../render3/debug/injector_profiler';
import {getInjectImplementation, setInjectImplementation} from './inject_switch';
import type {Injector} from './injector';
import {getCurrentInjector, setCurrentInjector} from './injector_compatibility';
import {getInjectImplementation, setInjectImplementation} from './inject_switch';
import {R3Injector} from './r3_injector';
/**
@@ -47,6 +48,12 @@ export function runInInjectionContext<ReturnT>(injector: Injector, fn: () => Ret
}
}
/**
* Whether the current stack frame is inside an injection context.
*/
export function isInInjectionContext(): boolean {
return getInjectImplementation() !== undefined || getCurrentInjector() != null;
}
/**
* Asserts that the current stack frame is within an [injection
* context](guide/dependency-injection-context) and has access to `inject`.
@@ -58,7 +65,7 @@ export function runInInjectionContext<ReturnT>(injector: Injector, fn: () => Ret
export function assertInInjectionContext(debugFn: Function): void {
// Taking a `Function` instead of a string name here prevents the unminified name of the function
// from being retained in the bundle regardless of minification.
if (!getInjectImplementation() && !getCurrentInjector()) {
if (!isInInjectionContext()) {
throw new RuntimeError(
RuntimeErrorCode.MISSING_INJECTION_CONTEXT,
ngDevMode &&
+5 -6
View File
@@ -10,6 +10,7 @@ import {setActiveConsumer} from '@angular/core/primitives/signals';
import {PartialObserver, Subject, Subscription} from 'rxjs';
import {OutputRef} from './authoring/output/output_ref';
import {isInInjectionContext} from './di/contextual';
import {inject} from './di/injector_compatibility';
import {DestroyRef} from './linker/destroy_ref';
@@ -107,18 +108,16 @@ export interface EventEmitter<T> extends Subject<T>, OutputRef<T> {
class EventEmitter_ extends Subject<any> implements OutputRef<any> {
__isAsync: boolean; // tslint:disable-line
destroyRef: DestroyRef|undefined;
destroyRef: DestroyRef|undefined = undefined;
constructor(isAsync: boolean = false) {
super();
this.__isAsync = isAsync;
// Attempt to retrieve a `DestroyRef` optionally.
// For backwards compatibility reasons, this cannot be required.
try {
this.destroyRef = inject(DestroyRef);
} catch {
this.destroyRef = undefined;
// For backwards compatibility reasons, this cannot be required
if (isInInjectionContext()) {
this.destroyRef = inject(DestroyRef, {optional: true}) ?? undefined;
}
}
+3 -1
View File
@@ -19,6 +19,7 @@ import {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TVi
import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
import {TransferState} from '../transfer_state';
import {isI18nHydrationSupportEnabled} from './api';
import {unsupportedProjectionOfDomNodes} from './error_handling';
import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces';
import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils';
@@ -526,7 +527,8 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean {
function annotateHostElementForHydration(
element: RElement, lView: LView, context: HydrationContext): number|null {
const renderer = lView[RENDERER];
if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) {
if ((hasI18n(lView) && !isI18nHydrationSupportEnabled()) ||
componentUsesShadowDomEncapsulation(lView)) {
// Attach the skip hydration attribute if this component:
// - either has i18n blocks, since hydrating such blocks is not yet supported
// - or uses ShadowDom view encapsulation, since Domino doesn't support
+30 -1
View File
@@ -12,6 +12,7 @@ import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironment
import {inject} from '../di/injector_compatibility';
import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors';
import {enableLocateOrCreateContainerRefImpl} from '../linker/view_container_ref';
import {enableLocateOrCreateI18nNodeImpl} from '../render3/i18n/i18n_apply';
import {enableLocateOrCreateElementNodeImpl} from '../render3/instructions/element';
import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructions/element_container';
import {enableApplyRootElementTransformImpl} from '../render3/instructions/shared';
@@ -24,7 +25,7 @@ import {performanceMarkFeature} from '../util/performance';
import {NgZone} from '../zone';
import {cleanupDehydratedViews} from './cleanup';
import {IS_HYDRATION_DOM_REUSE_ENABLED, PRESERVE_HOST_CONTENT} from './tokens';
import {IS_HYDRATION_DOM_REUSE_ENABLED, IS_I18N_HYDRATION_ENABLED, PRESERVE_HOST_CONTENT} from './tokens';
import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY, SSR_CONTENT_INTEGRITY_MARKER} from './utils';
import {enableFindMatchingDehydratedViewImpl} from './views';
@@ -34,6 +35,11 @@ import {enableFindMatchingDehydratedViewImpl} from './views';
*/
let isHydrationSupportEnabled = false;
/**
* Indicates whether support for hydrating i18n blocks is enabled.
*/
let _isI18nHydrationSupportEnabled = false;
/**
* Defines a period of time that Angular waits for the `ApplicationRef.isStable` to emit `true`.
* If there was no event with the `true` value during this time, Angular reports a warning.
@@ -62,6 +68,7 @@ function enableHydrationRuntimeSupport() {
enableLocateOrCreateContainerRefImpl();
enableFindMatchingDehydratedViewImpl();
enableApplyRootElementTransformImpl();
enableLocateOrCreateI18nNodeImpl();
}
}
@@ -145,6 +152,8 @@ export function withDomHydration(): EnvironmentProviders {
{
provide: ENVIRONMENT_INITIALIZER,
useValue: () => {
_isI18nHydrationSupportEnabled = !!inject(IS_I18N_HYDRATION_ENABLED, {optional: true});
// Since this function is used across both server and client,
// make sure that the runtime code is only added when invoked
// on the client. Moving forward, the `isPlatformBrowser` check should
@@ -198,6 +207,26 @@ export function withDomHydration(): EnvironmentProviders {
]);
}
/**
* Returns a set of providers required to setup support for i18n hydration.
* Requires hydration to be enabled separately.
*/
export function withI18nHydration(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: IS_I18N_HYDRATION_ENABLED,
useValue: true,
},
]);
}
/**
* Returns whether i18n hydration support is enabled.
*/
export function isI18nHydrationSupportEnabled(): boolean {
return _isI18nHydrationSupportEnabled;
}
/**
*
* @param time The time in ms until the stable timedout warning message is logged
+19 -1
View File
@@ -11,7 +11,7 @@ import {CONTAINER_HEADER_OFFSET, DEHYDRATED_VIEWS, LContainer} from '../render3/
import {Renderer} from '../render3/interfaces/renderer';
import {RNode} from '../render3/interfaces/renderer_dom';
import {isLContainer, isLView} from '../render3/interfaces/type_checks';
import {HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TVIEW} from '../render3/interfaces/view';
import {HEADER_OFFSET, HOST, HYDRATION, LView, PARENT, RENDERER, TVIEW} from '../render3/interfaces/view';
import {nativeRemoveNode} from '../render3/node_manipulation';
import {EMPTY_ARRAY} from '../util/empty';
@@ -68,11 +68,29 @@ function cleanupLContainer(lContainer: LContainer) {
}
}
/**
* Removes any remaining dehydrated i18n nodes from a given LView,
* both in internal data structure, as well as removing the
* corresponding DOM nodes.
*/
function cleanupDehydratedI18nNodes(lView: LView) {
const i18nNodes = lView[HYDRATION]?.i18nNodes;
if (i18nNodes) {
const renderer = lView[RENDERER];
for (const node of i18nNodes.values()) {
nativeRemoveNode(renderer, node, false);
}
lView[HYDRATION]!.i18nNodes = undefined;
}
}
/**
* Walks over `LContainer`s and components registered within
* this LView and invokes dehydrated views cleanup function for each one.
*/
function cleanupLView(lView: LView) {
cleanupDehydratedI18nNodes(lView);
const tView = lView[TVIEW];
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
if (isLContainer(lView[i])) {
+10
View File
@@ -165,6 +165,16 @@ export interface DehydratedView {
* nodes detected in this view at serialization time.
*/
disconnectedNodes?: Set<number>|null;
/**
* A mapping from a view to the first child to begin claiming nodes.
*
* This mapping is generated by an i18n block, and is the source of
* truth for the nodes inside of it. Once a view has been hydrated,
* the node is removed from the map. The remaining entries indicate
* dehydrated nodes that need to be cleaned up.
*/
i18nNodes?: Map<number, RNode>;
}
/**
@@ -43,6 +43,26 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) {
!(unwrapRNode(lView[tNode.index]) as Node)?.isConnected;
}
/**
* Locate a node in an i18n tree that corresponds to a given instruction index.
*
* @param hydrationInfo The hydration annotation data
* @param noOffsetIndex the instruction index
* @returns an RNode that corresponds to the instruction index
*/
export function locateI18nRNodeByIndex<T extends RNode>(
hydrationInfo: DehydratedView, noOffsetIndex: number): T|null {
const i18nNodes = hydrationInfo.i18nNodes;
if (i18nNodes) {
const native = i18nNodes.get(noOffsetIndex);
if (native) {
i18nNodes.delete(noOffsetIndex);
}
return native as T;
}
return null;
}
/**
* Locate a node in DOM tree that corresponds to a given TNode.
*
@@ -54,49 +74,52 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) {
*/
export function locateNextRNode<T extends RNode>(
hydrationInfo: DehydratedView, tView: TView, lView: LView<unknown>, tNode: TNode): T|null {
let native: RNode|null = null;
const noOffsetIndex = getNoOffsetIndex(tNode);
const nodes = hydrationInfo.data[NODES];
if (nodes?.[noOffsetIndex]) {
// We know the exact location of the node.
native = locateRNodeByPath(nodes[noOffsetIndex], lView);
} else if (tView.firstChild === tNode) {
// We create a first node in this view, so we use a reference
// to the first child in this DOM segment.
native = hydrationInfo.firstChild;
} else {
// Locate a node based on a previous sibling or a parent node.
const previousTNodeParent = tNode.prev === null;
const previousTNode = (tNode.prev ?? tNode.parent)!;
ngDevMode &&
assertDefined(
previousTNode,
'Unexpected state: current TNode does not have a connection ' +
'to the previous node or a parent node.');
if (isFirstElementInNgContainer(tNode)) {
const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!);
native = getSegmentHead(hydrationInfo, noOffsetParentIndex);
let native: RNode|null = locateI18nRNodeByIndex(hydrationInfo, noOffsetIndex);
if (!native) {
const nodes = hydrationInfo.data[NODES];
if (nodes?.[noOffsetIndex]) {
// We know the exact location of the node.
native = locateRNodeByPath(nodes[noOffsetIndex], lView);
} else if (tView.firstChild === tNode) {
// We create a first node in this view, so we use a reference
// to the first child in this DOM segment.
native = hydrationInfo.firstChild;
} else {
let previousRElement = getNativeByTNode(previousTNode, lView);
if (previousTNodeParent) {
native = (previousRElement as RElement).firstChild;
// Locate a node based on a previous sibling or a parent node.
const previousTNodeParent = tNode.prev === null;
const previousTNode = (tNode.prev ?? tNode.parent)!;
ngDevMode &&
assertDefined(
previousTNode,
'Unexpected state: current TNode does not have a connection ' +
'to the previous node or a parent node.');
if (isFirstElementInNgContainer(tNode)) {
const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!);
native = getSegmentHead(hydrationInfo, noOffsetParentIndex);
} else {
// If the previous node is an element, but it also has container info,
// this means that we are processing a node like `<div #vcrTarget>`, which is
// represented in the DOM as `<div></div>...<!--container-->`.
// In this case, there are nodes *after* this element and we need to skip
// all of them to reach an element that we are looking for.
const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode);
const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex);
if (previousTNode.type === TNodeType.Element && segmentHead) {
const numRootNodesToSkip =
calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex);
// `+1` stands for an anchor comment node after all the views in this container.
const nodesToSkip = numRootNodesToSkip + 1;
// First node after this segment.
native = siblingAfter(nodesToSkip, segmentHead);
let previousRElement = getNativeByTNode(previousTNode, lView);
if (previousTNodeParent) {
native = (previousRElement as RElement).firstChild;
} else {
native = previousRElement.nextSibling;
// If the previous node is an element, but it also has container info,
// this means that we are processing a node like `<div #vcrTarget>`, which is
// represented in the DOM as `<div></div>...<!--container-->`.
// In this case, there are nodes *after* this element and we need to skip
// all of them to reach an element that we are looking for.
const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode);
const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex);
if (previousTNode.type === TNodeType.Element && segmentHead) {
const numRootNodesToSkip =
calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex);
// `+1` stands for an anchor comment node after all the views in this container.
const nodesToSkip = numRootNodesToSkip + 1;
// First node after this segment.
native = siblingAfter(nodesToSkip, segmentHead);
} else {
native = previousRElement.nextSibling;
}
}
}
}
+7
View File
@@ -28,3 +28,10 @@ export const PRESERVE_HOST_CONTENT = new InjectionToken<boolean>(
providedIn: 'root',
factory: () => PRESERVE_HOST_CONTENT_DEFAULT,
});
/**
* Internal token that indicates whether hydration support for i18n
* is enabled.
*/
export const IS_I18N_HYDRATION_ENABLED = new InjectionToken<boolean>(
(typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_I18N_HYDRATION_ENABLED' : ''));
+18
View File
@@ -0,0 +1,18 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {TNode, TNodeFlags} from '../render3/interfaces/node';
/**
* Checks whether a TNode is considered detached, i.e. not present in the
* translated i18n template. We should not attempt hydration for such nodes
* and instead, use a regular "creation mode".
*/
export function isDetachedByI18n(tNode: TNode) {
return (tNode.flags & TNodeFlags.isDetached) === TNodeFlags.isDetached;
}
-15
View File
@@ -1,15 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* Used to patch behavior that needs to _temporarily_ be different between g3 and external.
*
* For example, make breaking changes ahead of the main branch targeting a major version.
* Permanent differences between g3 and external should be configured by individual patches.
*/
export const isG3 = false;
+12 -13
View File
@@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {assertInInjectionContext, Injector, ɵɵdefineInjectable} from '../di';
import {ChangeDetectionScheduler, NotificationType} from '../change_detection/scheduling/zoneless_scheduling';
import {assertInInjectionContext, Injector, runInInjectionContext, ɵɵdefineInjectable} from '../di';
import {inject} from '../di/injector_compatibility';
import {ErrorHandler} from '../error_handler';
import {DestroyRef} from '../linker/destroy_ref';
@@ -241,7 +242,7 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions
unregisterFn();
};
const unregisterFn = injector.get(DestroyRef).onDestroy(destroy);
const instance = new AfterRenderCallback(injector, phase, callback);
const instance = runInInjectionContext(injector, () => new AfterRenderCallback(phase, callback));
callbackHandler.register(instance);
return {destroy};
@@ -317,10 +318,10 @@ export function afterNextRender(
unregisterFn();
};
const unregisterFn = injector.get(DestroyRef).onDestroy(destroy);
const instance = new AfterRenderCallback(injector, phase, () => {
destroy();
callback();
});
const instance = runInInjectionContext(injector, () => new AfterRenderCallback(phase, () => {
destroy();
callback();
}));
callbackHandler.register(instance);
return {destroy};
@@ -330,14 +331,12 @@ export function afterNextRender(
* A wrapper around a function to be used as an after render callback.
*/
class AfterRenderCallback {
private zone: NgZone;
private errorHandler: ErrorHandler|null;
private zone = inject(NgZone);
private errorHandler = inject(ErrorHandler, {optional: true});
constructor(
injector: Injector, public readonly phase: AfterRenderPhase,
private callbackFn: VoidFunction) {
this.zone = injector.get(NgZone);
this.errorHandler = injector.get(ErrorHandler, null, {optional: true});
constructor(readonly phase: AfterRenderPhase, private callbackFn: VoidFunction) {
// Registering a callback will notify the scheduler.
inject(ChangeDetectionScheduler, {optional: true})?.notify(NotificationType.AfterRenderHooks);
}
invoke() {
+41 -6
View File
@@ -18,7 +18,7 @@ import {RElement, RNode, RText} from '../interfaces/renderer_dom';
import {SanitizerFn} from '../interfaces/sanitization';
import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view';
import {createCommentNode, createElementNode, createTextNode, nativeInsertBefore, nativeParentNode, nativeRemoveNode, updateTextNode} from '../node_manipulation';
import {getBindingIndex} from '../state';
import {getBindingIndex, lastNodeWasCreated, wasLastNodeCreated} from '../state';
import {renderStringify} from '../util/stringify_utils';
import {getNativeByIndex, unwrapRNode} from '../util/view_utils';
@@ -78,6 +78,39 @@ export function applyI18n(tView: TView, lView: LView, index: number) {
changeMaskCounter = 0;
}
function createNodeWithoutHydration(
lView: LView, textOrName: string,
nodeType: typeof Node.COMMENT_NODE|typeof Node.TEXT_NODE|typeof Node.ELEMENT_NODE) {
const renderer = lView[RENDERER];
switch (nodeType) {
case Node.COMMENT_NODE:
return createCommentNode(renderer, textOrName);
case Node.TEXT_NODE:
return createTextNode(renderer, textOrName);
case Node.ELEMENT_NODE:
return createElementNode(renderer, textOrName, null);
}
}
let _locateOrCreateNode: typeof locateOrCreateNodeImpl = (lView, index, textOrName, nodeType) => {
lastNodeWasCreated(true);
return createNodeWithoutHydration(lView, textOrName, nodeType);
};
function locateOrCreateNodeImpl(
lView: LView, index: number, textOrName: string,
nodeType: typeof Node.COMMENT_NODE|typeof Node.TEXT_NODE|typeof Node.ELEMENT_NODE) {
// TODO: Add support for hydration
lastNodeWasCreated(true);
return createNodeWithoutHydration(lView, textOrName, nodeType);
}
export function enableLocateOrCreateI18nNodeImpl() {
_locateOrCreateNode = locateOrCreateNodeImpl;
}
/**
* Apply `I18nCreateOpCodes` op-codes as stored in `TI18n.create`.
@@ -102,13 +135,15 @@ export function applyCreateOpCodes(
(opCode & I18nCreateOpCode.APPEND_EAGERLY) === I18nCreateOpCode.APPEND_EAGERLY;
const index = opCode >>> I18nCreateOpCode.SHIFT;
let rNode = lView[index];
let lastNodeWasCreated = false;
if (rNode === null) {
// We only create new DOM nodes if they don't already exist: If ICU switches case back to a
// case which was already instantiated, no need to create new DOM nodes.
rNode = lView[index] =
isComment ? renderer.createComment(text) : createTextNode(renderer, text);
_locateOrCreateNode(lView, index, text, isComment ? Node.COMMENT_NODE : Node.TEXT_NODE);
lastNodeWasCreated = wasLastNodeCreated();
}
if (appendNow && parentRNode !== null) {
if (appendNow && parentRNode !== null && lastNodeWasCreated) {
nativeInsertBefore(renderer, parentRNode, rNode, insertInFrontOf, false);
}
}
@@ -141,7 +176,7 @@ export function applyMutableOpCodes(
if (lView[textNodeIndex] === null) {
ngDevMode && ngDevMode.rendererCreateTextNode++;
ngDevMode && assertIndexInRange(lView, textNodeIndex);
lView[textNodeIndex] = createTextNode(renderer, opCode);
lView[textNodeIndex] = _locateOrCreateNode(lView, textNodeIndex, opCode, Node.TEXT_NODE);
}
} else if (typeof opCode == 'number') {
switch (opCode & IcuCreateOpCode.MASK_INSTRUCTION) {
@@ -219,7 +254,7 @@ export function applyMutableOpCodes(
ngDevMode && ngDevMode.rendererCreateComment++;
ngDevMode && assertIndexInExpandoRange(lView, commentNodeIndex);
const commentRNode = lView[commentNodeIndex] =
createCommentNode(renderer, commentValue);
_locateOrCreateNode(lView, commentNodeIndex, commentValue, Node.COMMENT_NODE);
// FIXME(misko): Attaching patch data is only needed for the root (Also add tests)
attachPatchData(commentRNode, lView);
}
@@ -236,7 +271,7 @@ export function applyMutableOpCodes(
ngDevMode && ngDevMode.rendererCreateElement++;
ngDevMode && assertIndexInExpandoRange(lView, elementNodeIndex);
const elementRNode = lView[elementNodeIndex] =
createElementNode(renderer, tagName, null);
_locateOrCreateNode(lView, elementNodeIndex, tagName, Node.ELEMENT_NODE);
// FIXME(misko): Attaching patch data is only needed for the root (Also add tests)
attachPatchData(elementRNode, lView);
}
+62 -22
View File
@@ -17,7 +17,7 @@ import {CharCode} from '../../util/char_code';
import {loadIcuContainerVisitor} from '../instructions/i18n_icu_container_visitor';
import {allocExpando, createTNodeAtIndex} from '../instructions/shared';
import {getDocument} from '../interfaces/document';
import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nRemoveOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n';
import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nElementNode, I18nNode, I18nNodeKind, I18nPlaceholderNode, I18nPlaceholderType, I18nRemoveOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n';
import {TNode, TNodeType} from '../interfaces/node';
import {SanitizerFn} from '../interfaces/sanitization';
import {HEADER_OFFSET, LView, TView} from '../interfaces/view';
@@ -88,6 +88,7 @@ export function i18nStartFirstCreatePass(
const createOpCodes: I18nCreateOpCodes = [] as any;
const updateOpCodes: I18nUpdateOpCodes = [] as any;
const existingTNodeStack: TNode[][] = [[]];
const astStack: Array<Array<I18nNode>> = [[]];
if (ngDevMode) {
attachDebugGetter(createOpCodes, i18nCreateOpCodesToString);
attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString);
@@ -108,7 +109,8 @@ export function i18nStartFirstCreatePass(
ngDevMode && assertString(text, 'Parsed ICU part should be string');
if (text !== '') {
i18nStartFirstCreatePassProcessTextNode(
tView, rootTNode, existingTNodeStack[0], createOpCodes, updateOpCodes, lView, text);
astStack[0], tView, rootTNode, existingTNodeStack[0], createOpCodes, updateOpCodes,
lView, text);
}
} else {
// `j` is Even therefor `part` is an `ICUExpression`
@@ -129,7 +131,9 @@ export function i18nStartFirstCreatePass(
ngDevMode &&
assertGreaterThanOrEqual(
icuNodeIndex, HEADER_OFFSET, 'Index must be in absolute LView offset');
icuStart(tView, lView, updateOpCodes, parentTNodeIndex, icuExpression, icuNodeIndex);
icuStart(
astStack[0], tView, lView, updateOpCodes, parentTNodeIndex, icuExpression,
icuNodeIndex);
}
}
} else {
@@ -141,11 +145,22 @@ export function i18nStartFirstCreatePass(
const index = HEADER_OFFSET + Number.parseInt(value.substring((isClosing ? 2 : 1)));
if (isClosing) {
existingTNodeStack.shift();
astStack.shift();
setCurrentTNode(getCurrentParentTNode()!, false);
} else {
const tNode = createTNodePlaceholder(tView, existingTNodeStack[0], index);
existingTNodeStack.unshift([]);
setCurrentTNode(tNode, true);
const placeholderNode: I18nPlaceholderNode = {
kind: I18nNodeKind.PLACEHOLDER,
index,
children: [],
type: type === CharCode.HASH ? I18nPlaceholderType.ELEMENT :
I18nPlaceholderType.SUBTEMPLATE,
};
astStack[0].push(placeholderNode);
astStack.unshift(placeholderNode.children);
}
}
}
@@ -153,6 +168,7 @@ export function i18nStartFirstCreatePass(
tView.data[index] = <TI18n>{
create: createOpCodes,
update: updateOpCodes,
ast: astStack[0],
};
}
@@ -229,14 +245,17 @@ function createTNodeAndAddOpCode(
* @param text The translated text (which may contain binding)
*/
function i18nStartFirstCreatePassProcessTextNode(
tView: TView, rootTNode: TNode|null, existingTNodes: TNode[], createOpCodes: I18nCreateOpCodes,
updateOpCodes: I18nUpdateOpCodes, lView: LView, text: string): void {
ast: I18nNode[], tView: TView, rootTNode: TNode|null, existingTNodes: TNode[],
createOpCodes: I18nCreateOpCodes, updateOpCodes: I18nUpdateOpCodes, lView: LView,
text: string): void {
const hasBinding = text.match(BINDING_REGEXP);
const tNode = createTNodeAndAddOpCode(
tView, rootTNode, existingTNodes, lView, createOpCodes, hasBinding ? null : text, false);
const index = tNode.index;
if (hasBinding) {
generateBindingUpdateOpCodes(updateOpCodes, text, tNode.index, null, 0, null);
generateBindingUpdateOpCodes(updateOpCodes, text, index, null, 0, null);
}
ast.push({kind: I18nNodeKind.TEXT, index});
}
/**
@@ -366,7 +385,7 @@ function toMaskBit(bindingIndex: number): number {
return 1 << Math.min(bindingIndex, 31);
}
export function isRootTemplateMessage(subTemplateIndex: number): subTemplateIndex is - 1 {
function isRootTemplateMessage(subTemplateIndex: number): subTemplateIndex is - 1 {
return subTemplateIndex === -1;
}
@@ -441,9 +460,9 @@ export function getTranslationForTemplate(message: string, subTemplateIndex: num
* - `lView[anchorIdx]` points to a `Comment` node representing the anchor for the ICU.
* - `tView.data[anchorIdx]` points to the `TIcuContainerNode` if ICU is root (`null` otherwise)
*/
export function icuStart(
tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number,
icuExpression: IcuExpression, anchorIdx: number) {
function icuStart(
ast: I18nNode[], tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes,
parentIdx: number, icuExpression: IcuExpression, anchorIdx: number) {
ngDevMode && assertDefined(icuExpression, 'ICU expression must be defined');
let bindingMask = 0;
const tIcu: TIcu = {
@@ -458,6 +477,7 @@ export function icuStart(
addUpdateIcuSwitch(updateOpCodes, icuExpression, anchorIdx);
setTIcu(tView, anchorIdx, tIcu);
const values = icuExpression.values;
const cases: I18nNode[][] = [];
for (let i = 0; i < values.length; i++) {
// Each value is an array of strings & other ICU expressions
const valueArr = values[i];
@@ -471,14 +491,22 @@ export function icuStart(
valueArr[j] = `<!--<2D>${icuIndex}<EFBFBD>-->`;
}
}
const caseAst: I18nNode[] = [];
cases.push(caseAst);
bindingMask = parseIcuCase(
tView, tIcu, lView, updateOpCodes, parentIdx, icuExpression.cases[i],
caseAst, tView, tIcu, lView, updateOpCodes, parentIdx, icuExpression.cases[i],
valueArr.join(''), nestedIcus) |
bindingMask;
}
if (bindingMask) {
addUpdateIcuUpdate(updateOpCodes, bindingMask, anchorIdx);
}
ast.push({
kind: I18nNodeKind.ICU,
index: anchorIdx,
cases,
currentCaseLViewIndex: tIcu.currentCaseLViewIndex
});
}
/**
@@ -488,7 +516,7 @@ export function icuStart(
* @param pattern Text containing an ICU expression that needs to be parsed.
*
*/
export function parseICUBlock(pattern: string): IcuExpression {
function parseICUBlock(pattern: string): IcuExpression {
const cases = [];
const values: (string|IcuExpression)[][] = [];
let icuType = IcuType.plural;
@@ -536,7 +564,7 @@ export function parseICUBlock(pattern: string): IcuExpression {
* - odd positions: `string` => text between ICU expressions
* - even positions: `ICUExpression` => ICU expression parsed into `ICUExpression` record.
*/
export function i18nParseTextIntoPartsAndICU(pattern: string): (string|IcuExpression)[] {
function i18nParseTextIntoPartsAndICU(pattern: string): (string|IcuExpression)[] {
if (!pattern) {
return [];
}
@@ -585,9 +613,10 @@ export function i18nParseTextIntoPartsAndICU(pattern: string): (string|IcuExpres
* Parses a node, its children and its siblings, and generates the mutate & update OpCodes.
*
*/
export function parseIcuCase(
tView: TView, tIcu: TIcu, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number,
caseName: string, unsafeCaseHtml: string, nestedIcus: IcuExpression[]): number {
function parseIcuCase(
ast: I18nNode[], tView: TView, tIcu: TIcu, lView: LView, updateOpCodes: I18nUpdateOpCodes,
parentIdx: number, caseName: string, unsafeCaseHtml: string,
nestedIcus: IcuExpression[]): number {
const create: IcuCreateOpCodes = [] as any;
const remove: I18nRemoveOpCodes = [] as any;
const update: I18nUpdateOpCodes = [] as any;
@@ -607,7 +636,7 @@ export function parseIcuCase(
const inertRootNode = getTemplateContent(inertBodyElement!) as Element || inertBodyElement;
if (inertRootNode) {
return walkIcuTree(
tView, tIcu, lView, updateOpCodes, create, remove, update, inertRootNode, parentIdx,
ast, tView, tIcu, lView, updateOpCodes, create, remove, update, inertRootNode, parentIdx,
nestedIcus, 0);
} else {
return 0;
@@ -615,7 +644,7 @@ export function parseIcuCase(
}
function walkIcuTree(
tView: TView, tIcu: TIcu, lView: LView, sharedUpdateOpCodes: I18nUpdateOpCodes,
ast: I18nNode[], tView: TView, tIcu: TIcu, lView: LView, sharedUpdateOpCodes: I18nUpdateOpCodes,
create: IcuCreateOpCodes, remove: I18nRemoveOpCodes, update: I18nUpdateOpCodes,
parentNode: Element, parentIdx: number, nestedIcus: IcuExpression[], depth: number): number {
let bindingMask = 0;
@@ -654,10 +683,17 @@ function walkIcuTree(
addCreateAttribute(create, newIndex, attr);
}
}
const elementNode: I18nElementNode = {
kind: I18nNodeKind.ELEMENT,
index: newIndex,
children: [],
};
ast.push(elementNode);
// Parse the children of this node (if any)
bindingMask = walkIcuTree(
tView, tIcu, lView, sharedUpdateOpCodes, create, remove, update,
currentNode as Element, newIndex, nestedIcus, depth + 1) |
bindingMask =
walkIcuTree(
elementNode.children, tView, tIcu, lView, sharedUpdateOpCodes, create, remove,
update, currentNode as Element, newIndex, nestedIcus, depth + 1) |
bindingMask;
addRemoveNode(remove, newIndex, depth);
}
@@ -671,6 +707,10 @@ function walkIcuTree(
bindingMask =
generateBindingUpdateOpCodes(update, value, newIndex, null, 0, null) | bindingMask;
}
ast.push({
kind: I18nNodeKind.TEXT,
index: newIndex,
});
break;
case Node.COMMENT_NODE:
// Check if the comment node is a placeholder for a nested ICU
@@ -682,7 +722,7 @@ function walkIcuTree(
addCreateNodeAndAppend(
create, ICU_MARKER, ngDevMode ? `nested ICU ${nestedIcuIndex}` : '', parentIdx,
newIndex);
icuStart(tView, lView, sharedUpdateOpCodes, parentIdx, icuExpression, newIndex);
icuStart(ast, tView, lView, sharedUpdateOpCodes, parentIdx, icuExpression, newIndex);
addRemoveNestedIcu(remove, newIndex, depth);
}
break;
@@ -11,7 +11,7 @@ import {setActiveConsumer} from '@angular/core/primitives/signals';
import {TrackByFunction} from '../../change_detection';
import {DehydratedContainerView} from '../../hydration/interfaces';
import {findMatchingDehydratedView} from '../../hydration/views';
import {assertDefined} from '../../util/assert';
import {assertDefined, assertFunction} from '../../util/assert';
import {performanceMarkFeature} from '../../util/performance';
import {assertLContainer, assertLView, assertTNode} from '../assert';
import {bindingUpdated} from '../bindings';
@@ -150,6 +150,11 @@ export function ɵɵrepeaterCreate(
emptyDecls?: number, emptyVars?: number, emptyTagName?: string|null,
emptyAttrsIndex?: number|null): void {
performanceMarkFeature('NgControlFlow');
ngDevMode &&
assertFunction(
trackByFn, `A track expression must be a function, was ${typeof trackByFn} instead.`);
const hasEmptyBlock = emptyTemplateFn !== undefined;
const hostLView = getLView();
const boundTrackBy = trackByUsesComponentInstance ?
@@ -10,6 +10,7 @@ import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from
import {locateNextRNode} from '../../hydration/node_lookup_utils';
import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode} from '../../hydration/skip_hydration';
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, markRNodeAsSkippedByHydration, setSegmentHead} from '../../hydration/utils';
import {isDetachedByI18n} from '../../i18n/utils';
import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert';
import {assertFirstCreatePass, assertHasParent} from '../assert';
import {attachPatchData} from '../context_discovery';
@@ -102,7 +103,7 @@ export function ɵɵelementStart(
setCurrentTNode(tNode, true);
setupStaticAttributes(renderer, native, tNode);
if ((tNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached && wasLastNodeCreated()) {
if (!isDetachedByI18n(tNode) && wasLastNodeCreated()) {
// In the i18n case, the translation may have removed this element, so only add it if it is not
// detached. See `TNodeType.Placeholder` and `LFrame.inI18n` for more context.
appendChild(tView, lView, native, tNode);
@@ -203,8 +204,8 @@ function locateOrCreateElementNodeImpl(
tView: TView, lView: LView, tNode: TNode, renderer: Renderer, name: string,
index: number): RElement {
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode =
!hydrationInfo || isInSkipHydrationBlock() || isDisconnectedNode(hydrationInfo, index);
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() ||
isDetachedByI18n(tNode) || isDisconnectedNode(hydrationInfo, index);
lastNodeWasCreated(isNodeCreationMode);
// Regular creation mode.
@@ -8,6 +8,7 @@
import {validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
import {locateNextRNode, siblingAfter} from '../../hydration/node_lookup_utils';
import {getNgContainerSize, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
import {isDetachedByI18n} from '../../i18n/utils';
import {assertEqual, assertIndexInRange, assertNumber} from '../../util/assert';
import {assertHasParent} from '../assert';
import {attachPatchData} from '../context_discovery';
@@ -164,7 +165,7 @@ function locateOrCreateElementContainerNode(
tView: TView, lView: LView, tNode: TNode, index: number): RComment {
let comment: RComment;
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock();
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() || isDetachedByI18n(tNode);
lastNodeWasCreated(isNodeCreationMode);
@@ -9,6 +9,7 @@ import {validateMatchingNode, validateNodeExists} from '../../hydration/error_ha
import {TEMPLATES} from '../../hydration/interfaces';
import {locateNextRNode, siblingAfter} from '../../hydration/node_lookup_utils';
import {calcSerializedContainerSize, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
import {isDetachedByI18n} from '../../i18n/utils';
import {populateDehydratedViewsInLContainer} from '../../linker/view_container_ref';
import {assertEqual} from '../../util/assert';
import {assertFirstCreatePass} from '../assert';
@@ -132,8 +133,8 @@ function createContainerAnchorImpl(
function locateOrCreateContainerAnchorImpl(
tView: TView, lView: LView, tNode: TNode, index: number): RComment {
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode =
!hydrationInfo || isInSkipHydrationBlock() || isDisconnectedNode(hydrationInfo, index);
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() ||
isDetachedByI18n(tNode) || isDisconnectedNode(hydrationInfo, index);
lastNodeWasCreated(isNodeCreationMode);
// Regular creation mode.
@@ -8,6 +8,7 @@
import {validateMatchingNode} from '../../hydration/error_handling';
import {locateNextRNode} from '../../hydration/node_lookup_utils';
import {isDisconnectedNode, markRNodeAsClaimedByHydration} from '../../hydration/utils';
import {isDetachedByI18n} from '../../i18n/utils';
import {assertEqual, assertIndexInRange} from '../../util/assert';
import {TElementNode, TNode, TNodeType} from '../interfaces/node';
import {RText} from '../interfaces/renderer_dom';
@@ -66,8 +67,8 @@ let _locateOrCreateTextNode: typeof locateOrCreateTextNodeImpl =
function locateOrCreateTextNodeImpl(
tView: TView, lView: LView, tNode: TNode, value: string, index: number): RText {
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode =
!hydrationInfo || isInSkipHydrationBlock() || isDisconnectedNode(hydrationInfo, index);
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() ||
isDetachedByI18n(tNode) || isDisconnectedNode(hydrationInfo, index);
lastNodeWasCreated(isNodeCreationMode);
// Regular creation mode.
@@ -341,6 +341,12 @@ export interface TI18n {
* DOM are required.
*/
update: I18nUpdateOpCodes;
/**
* An AST representing the translated message. This is used for hydration (and serialization),
* while the Update and Create OpCodes are used at runtime.
*/
ast: Array<I18nNode>;
}
/**
@@ -408,3 +414,79 @@ export interface IcuExpression {
cases: string[];
values: (string|IcuExpression)[][];
}
// A parsed I18n AST Node
export type I18nNode = I18nTextNode|I18nElementNode|I18nICUNode|I18nPlaceholderNode;
/**
* Represents a block of text in a translation, such as `Hello, {{ name }}!`.
*/
export interface I18nTextNode {
/** The AST node kind */
kind: I18nNodeKind.TEXT;
/** The LView index */
index: number;
}
/**
* Represents a simple DOM element in a translation, such as `<div>...</div>`
*/
export interface I18nElementNode {
/** The AST node kind */
kind: I18nNodeKind.ELEMENT;
/** The LView index */
index: number;
/** The child nodes */
children: Array<I18nNode>;
}
/**
* Represents an ICU in a translation.
*/
export interface I18nICUNode {
/** The AST node kind */
kind: I18nNodeKind.ICU;
/** The LView index */
index: number;
/** The branching cases */
cases: Array<Array<I18nNode>>;
/** The LView index that stores the active case */
currentCaseLViewIndex: number;
}
/**
* Represents special content that is embedded into the translation. This can
* either be a special built-in element, such as <ng-container> and <ng-content>,
* or it can be a sub-template, for example, from a structural directive.
*/
export interface I18nPlaceholderNode {
/** The AST node kind */
kind: I18nNodeKind.PLACEHOLDER;
/** The LView index */
index: number;
/** The child nodes */
children: Array<I18nNode>;
/** The placeholder type */
type: I18nPlaceholderType;
}
export const enum I18nPlaceholderType {
ELEMENT,
SUBTEMPLATE,
}
export const enum I18nNodeKind {
TEXT,
ELEMENT,
PLACEHOLDER,
ICU,
}
@@ -8,6 +8,7 @@
import {consumerDestroy, getActiveConsumer, setActiveConsumer} from '@angular/core/primitives/signals';
import {NotificationType} from '../change_detection/scheduling/zoneless_scheduling';
import {hasInSkipHydrationBlockFlag} from '../hydration/skip_hydration';
import {ViewEncapsulation} from '../metadata/view';
import {RendererStyleFlags2} from '../render/api_flags';
@@ -173,6 +174,10 @@ export function addViewToDOM(
* @param lView the `LView` to be detached.
*/
export function detachViewFromDOM(tView: TView, lView: LView) {
// When we remove a view from the DOM, we need to rerun afterRender hooks
// We don't necessarily needs to run change detection. DOM removal only requires
// change detection if animations are enabled (this notification is handled by animations).
lView[ENVIRONMENT].changeDetectionScheduler?.notify(NotificationType.AfterRenderHooks);
applyView(tView, lView, lView[RENDERER], WalkTNodeTreeAction.Detach, null, null);
}
@@ -10,57 +10,53 @@ import '../util/ng_dev_mode';
import {assertDefined, assertEqual, assertNotEqual} from '../util/assert';
import {AttributeMarker} from './interfaces/attribute_marker';
import {TAttributes, TNode, TNodeType} from './interfaces/node';
import {CssSelector, CssSelectorList, SelectorFlags} from './interfaces/projection';
import {classIndexOf} from './styling/class_differ';
import {isNameOnlyAttributeMarker} from './util/attrs_utils';
import { AttributeMarker } from './interfaces/attribute_marker';
const NG_TEMPLATE_SELECTOR = 'ng-template';
/**
* Search the `TAttributes` to see if it contains `cssClassToMatch` (case insensitive)
*
* @param tNode static data of the node to match
* @param attrs `TAttributes` to search through.
* @param cssClassToMatch class to match (lowercase)
* @param isProjectionMode Whether or not class matching should look into the attribute `class` in
* addition to the `AttributeMarker.Classes`.
*/
function isCssClassMatching(
attrs: TAttributes, cssClassToMatch: string, isProjectionMode: boolean): boolean {
// TODO(misko): The fact that this function needs to know about `isProjectionMode` seems suspect.
// It is strange to me that sometimes the class information comes in form of `class` attribute
// and sometimes in form of `AttributeMarker.Classes`. Some investigation is needed to determine
// if that is the right behavior.
tNode: TNode, attrs: TAttributes, cssClassToMatch: string, isProjectionMode: boolean): boolean {
ngDevMode &&
assertEqual(
cssClassToMatch, cssClassToMatch.toLowerCase(), 'Class name expected to be lowercase.');
let i = 0;
// Indicates whether we are processing value from the implicit
// attribute section (i.e. before the first marker in the array).
let isImplicitAttrsSection = true;
while (i < attrs.length) {
let item = attrs[i++];
if (typeof item === 'string' && isImplicitAttrsSection) {
const value = attrs[i++] as string;
if (isProjectionMode && item === 'class') {
// We found a `class` attribute in the implicit attribute section,
// check if it matches the value of the `cssClassToMatch` argument.
if (classIndexOf(value.toLowerCase(), cssClassToMatch, 0) !== -1) {
return true;
}
if (isProjectionMode) {
for (; i < attrs.length && typeof attrs[i] === 'string'; i += 2) {
// Search for an implicit `class` attribute and check if its value matches `cssClassToMatch`.
if (attrs[i] === 'class' &&
classIndexOf((attrs[i + 1] as string).toLowerCase(), cssClassToMatch, 0) !== -1) {
return true;
}
} else if (item === AttributeMarker.Classes) {
// We found the classes section. Start searching for the class.
while (i < attrs.length && typeof (item = attrs[i++]) == 'string') {
// while we have strings
if (item.toLowerCase() === cssClassToMatch) return true;
}
} else if (isInlineTemplate(tNode)) {
// Matching directives (i.e. when not matching for projection mode) should not consider the
// class bindings that are present on inline templates, as those class bindings only target
// the root node of the template, not the template itself.
return false;
}
// Resume the search for classes after the `Classes` marker.
i = attrs.indexOf(AttributeMarker.Classes, i);
if (i > -1) {
// We found the classes section. Start searching for the class.
let item: TAttributes[number];
while (++i < attrs.length && typeof (item = attrs[i]) === 'string') {
if (item.toLowerCase() === cssClassToMatch) {
return true;
}
return false;
} else if (typeof item === 'number') {
// We've came across a first marker, which indicates
// that the implicit attribute section is over.
isImplicitAttrsSection = false;
}
}
return false;
@@ -96,7 +92,7 @@ function hasTagAndTypeMatch(
/**
* A utility function to match an Ivy node static data against a simple CSS selector
*
* @param node static data of the node to match
* @param tNode static data of the node to match
* @param selector The selector to try matching against the node.
* @param isProjectionMode if `true` we are matching for content projection, otherwise we are doing
* directive matching.
@@ -106,10 +102,10 @@ export function isNodeMatchingSelector(
tNode: TNode, selector: CssSelector, isProjectionMode: boolean): boolean {
ngDevMode && assertDefined(selector[0], 'Selector should have a tag name');
let mode: SelectorFlags = SelectorFlags.ELEMENT;
const nodeAttrs = tNode.attrs || [];
const nodeAttrs = tNode.attrs;
// Find the index of first attribute that has no value, only a name.
const nameOnlyMarkerIdx = getNameOnlyMarkerIndex(nodeAttrs);
const nameOnlyMarkerIdx = nodeAttrs !== null ? getNameOnlyMarkerIndex(nodeAttrs) : 0;
// When processing ":not" selectors, we skip to the next ":not" if the
// current one doesn't match
@@ -139,22 +135,15 @@ export function isNodeMatchingSelector(
if (isPositive(mode)) return false;
skipToNextSelector = true;
}
} else {
const selectorAttrValue = mode & SelectorFlags.CLASS ? current : selector[++i];
// special case for matching against classes when a tNode has been instantiated with
// class and style values as separate attribute values (e.g. ['title', CLASS, 'foo'])
if ((mode & SelectorFlags.CLASS) && tNode.attrs !== null) {
if (!isCssClassMatching(tNode.attrs, selectorAttrValue as string, isProjectionMode)) {
if (isPositive(mode)) return false;
skipToNextSelector = true;
}
continue;
} else if (mode & SelectorFlags.CLASS) {
if (nodeAttrs === null || !isCssClassMatching(tNode, nodeAttrs, current, isProjectionMode)) {
if (isPositive(mode)) return false;
skipToNextSelector = true;
}
const attrName = (mode & SelectorFlags.CLASS) ? 'class' : current;
} else {
const selectorAttrValue = selector[++i];
const attrIndexInNode =
findAttrIndexInNode(attrName, nodeAttrs, isInlineTemplate(tNode), isProjectionMode);
findAttrIndexInNode(current, nodeAttrs, isInlineTemplate(tNode), isProjectionMode);
if (attrIndexInNode === -1) {
if (isPositive(mode)) return false;
@@ -169,18 +158,15 @@ export function isNodeMatchingSelector(
} else {
ngDevMode &&
assertNotEqual(
nodeAttrs[attrIndexInNode], AttributeMarker.NamespaceURI,
nodeAttrs![attrIndexInNode], AttributeMarker.NamespaceURI,
'We do not match directives on namespaced attributes');
// we lowercase the attribute value to be able to match
// selectors without case-sensitivity
// (selectors are already in lowercase when generated)
nodeAttrValue = (nodeAttrs[attrIndexInNode + 1] as string).toLowerCase();
nodeAttrValue = (nodeAttrs![attrIndexInNode + 1] as string).toLowerCase();
}
const compareAgainstClassName = mode & SelectorFlags.CLASS ? nodeAttrValue : null;
if (compareAgainstClassName &&
classIndexOf(compareAgainstClassName, selectorAttrValue as string, 0) !== -1 ||
mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) {
if (mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) {
if (isPositive(mode)) return false;
skipToNextSelector = true;
}
@@ -40,7 +40,5 @@ export function queueStateUpdate(callback: VoidFunction, options?: {injector?: I
};
internalAfterNextRender(runCallbackOnce, {injector, runOnServer: true});
queueMicrotask(() => {
runCallbackOnce();
});
queueMicrotask(runCallbackOnce);
}

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