From bdd61c768a28b56c68634b99c036986499829f45 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Mon, 16 Oct 2023 13:57:53 -0700 Subject: [PATCH 01/52] fix(core): replace assertion with more intentional error (#52234) Issue #50320 shows that in some cases, updating a signal that's a dependency of a template during change detection of that template can have several adverse effects. This can happen, for example, if the signal is set during the lifecycle hook of a directive within the same template that reads the signal. This can cause a few things to happen: * Straightforwardly, it can cause `ExpressionChanged` errors. * Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer` to fail. * Very surprisingly, it can cause change detection for an `OnPush` component to stop working. The root cause of these later behaviors is subtle, and is ultimately a desync between the reactive graph and the view tree's notion of "dirty" for a given view. This will be fixed with further work planned for change detection to handle such updates directly. Until then, this commit improves the DX through two changes: 1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is changed to use the `consumerOnSignalRead` hook from the reactive graph. This prevents the situation which required the assertion in the first place. 2. A `console.warn` warning is added when a view is marked dirty via a signal while it's still executing. The warning informs users that they're pushing data against the direction of change detection, risking `ExpressionChanged` or other issues. It's a warning and not an error because the check is overly broad and captures situations where the application would not actually break as a result, such as if a `computed` marked the template dirty but still returned the same value. PR Close #52234 --- goldens/size-tracking/aio-payloads.json | 2 +- .../render3/instructions/change_detection.ts | 28 +++++--- .../core/src/render3/instructions/shared.ts | 12 ++-- .../src/render3/reactive_lview_consumer.ts | 61 +++++++--------- .../change_detection_signals_in_zones_spec.ts | 72 ++++++++++++++++++- .../bundle.golden_symbols.json | 9 --- .../animations/bundle.golden_symbols.json | 9 --- .../cyclic_import/bundle.golden_symbols.json | 9 --- .../bundling/defer/bundle.golden_symbols.json | 9 --- .../forms_reactive/bundle.golden_symbols.json | 9 --- .../bundle.golden_symbols.json | 9 --- .../hello_world/bundle.golden_symbols.json | 9 --- .../hydration/bundle.golden_symbols.json | 9 --- .../router/bundle.golden_symbols.json | 9 --- .../bundle.golden_symbols.json | 9 --- .../bundling/todo/bundle.golden_symbols.json | 9 --- 16 files changed, 121 insertions(+), 153 deletions(-) diff --git a/goldens/size-tracking/aio-payloads.json b/goldens/size-tracking/aio-payloads.json index 0dcf5dac28751..42f6c4bd6aaf4 100755 --- a/goldens/size-tracking/aio-payloads.json +++ b/goldens/size-tracking/aio-payloads.json @@ -12,7 +12,7 @@ "aio-local": { "uncompressed": { "runtime": 4252, - "main": 507632, + "main": 512673, "polyfills": 33862, "styles": 60209, "light-theme": 34317, diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 8fe630464d2de..5e292d8c54770 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -13,7 +13,7 @@ import {getComponentViewByInstance} from '../context_discovery'; import {executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags} from '../hooks'; import {CONTAINER_HEADER_OFFSET, HAS_CHILD_VIEWS_TO_REFRESH, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS} from '../interfaces/container'; import {ComponentTemplate, RenderFlags} from '../interfaces/definition'; -import {CONTEXT, ENVIRONMENT, FLAGS, InitPhaseState, LView, LViewFlags, PARENT, TVIEW, TView} from '../interfaces/view'; +import {CONTEXT, ENVIRONMENT, FLAGS, InitPhaseState, LView, LViewFlags, PARENT, REACTIVE_TEMPLATE_CONSUMER, TVIEW, TView} from '../interfaces/view'; import {enterView, isInCheckNoChangesMode, leaveView, setBindingIndex, setIsInCheckNoChangesMode} from '../state'; import {getFirstLContainer, getNextLContainer} from '../util/view_traversal_utils'; import {getComponentLViewByIndex, isCreationMode, markAncestorsForTraversal, markViewForRefresh, resetPreOrderHookFlags, viewAttachedToChangeDetector} from '../util/view_utils'; @@ -159,17 +159,23 @@ export function refreshView( // execute pre-order hooks (OnInit, OnChanges, DoCheck) // PERF WARNING: do NOT extract this to a separate function without running benchmarks if (!isInCheckNoChangesPass) { - if (hooksInitPhaseCompleted) { - const preOrderCheckHooks = tView.preOrderCheckHooks; - if (preOrderCheckHooks !== null) { - executeCheckHooks(lView, preOrderCheckHooks, null); - } - } else { - const preOrderHooks = tView.preOrderHooks; - if (preOrderHooks !== null) { - executeInitAndCheckHooks(lView, preOrderHooks, InitPhaseState.OnInitHooksToBeRun, null); + const consumer = lView[REACTIVE_TEMPLATE_CONSUMER]; + try { + consumer && (consumer.isRunning = true); + if (hooksInitPhaseCompleted) { + const preOrderCheckHooks = tView.preOrderCheckHooks; + if (preOrderCheckHooks !== null) { + executeCheckHooks(lView, preOrderCheckHooks, null); + } + } else { + const preOrderHooks = tView.preOrderHooks; + if (preOrderHooks !== null) { + executeInitAndCheckHooks(lView, preOrderHooks, InitPhaseState.OnInitHooksToBeRun, null); + } + incrementInitPhaseFlags(lView, InitPhaseState.OnInitHooksToBeRun); } - incrementInitPhaseFlags(lView, InitPhaseState.OnInitHooksToBeRun); + } finally { + consumer && (consumer.isRunning = false); } } diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 7b86be4bb5e14..5339058590c2e 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -43,7 +43,7 @@ import {assertPureTNodeType, assertTNodeType} from '../node_assert'; import {clearElementContents, updateTextNode} from '../node_manipulation'; import {isInlineTemplate, isNodeMatchingSelectorList} from '../node_selector_matcher'; import {profiler, ProfilerEvent} from '../profiler'; -import {commitLViewConsumerIfHasProducers, getReactiveLViewConsumer} from '../reactive_lview_consumer'; +import {getReactiveLViewConsumer} from '../reactive_lview_consumer'; import {getBindingsEnabled, getCurrentDirectiveIndex, getCurrentParentTNode, getCurrentTNodePlaceholderOk, getSelectedIndex, isCurrentTNodeParent, isInCheckNoChangesMode, isInI18nBlock, isInSkipHydrationBlock, setBindingRootForHostBindings, setCurrentDirectiveIndex, setCurrentQueryIndex, setCurrentTNode, setSelectedIndex} from '../state'; import {NO_CHANGE} from '../tokens'; import {mergeHostAttrs} from '../util/attrs_utils'; @@ -82,18 +82,17 @@ export function processHostBindingOpCodes(tView: TView, lView: LView): void { setBindingRootForHostBindings(bindingRootIndx, directiveIdx); consumer.dirty = false; const prevConsumer = consumerBeforeComputation(consumer); + consumer.isRunning = true; try { const context = lView[directiveIdx]; hostBindingFn(RenderFlags.Update, context); } finally { consumerAfterComputation(consumer, prevConsumer); + consumer.isRunning = false; } } } } finally { - if (lView[REACTIVE_HOST_BINDING_CONSUMER] === null) { - commitLViewConsumerIfHasProducers(lView, REACTIVE_HOST_BINDING_CONSUMER); - } setSelectedIndex(-1); } } @@ -275,15 +274,14 @@ export function executeTemplate( try { if (effectiveConsumer !== null) { effectiveConsumer.dirty = false; + effectiveConsumer.isRunning = true; } templateFn(rf, context); } finally { consumerAfterComputation(effectiveConsumer, prevConsumer); + effectiveConsumer && (effectiveConsumer.isRunning = false); } } finally { - if (isUpdatePhase && lView[REACTIVE_TEMPLATE_CONSUMER] === null) { - commitLViewConsumerIfHasProducers(lView, REACTIVE_TEMPLATE_CONSUMER); - } setSelectedIndex(prevSelectedIndex); const postHookType = diff --git a/packages/core/src/render3/reactive_lview_consumer.ts b/packages/core/src/render3/reactive_lview_consumer.ts index 7c6eedf1f6dad..ed7cdedc15aa2 100644 --- a/packages/core/src/render3/reactive_lview_consumer.ts +++ b/packages/core/src/render3/reactive_lview_consumer.ts @@ -8,14 +8,17 @@ import {REACTIVE_NODE, ReactiveNode} from '@angular/core/primitives/signals'; -import {assertDefined, assertEqual} from '../util/assert'; +import {RuntimeError} from '../errors'; +import {assertDefined} from '../util/assert'; import {markViewDirty} from './instructions/mark_view_dirty'; import {LView, REACTIVE_HOST_BINDING_CONSUMER, REACTIVE_TEMPLATE_CONSUMER} from './interfaces/view'; let currentConsumer: ReactiveLViewConsumer|null = null; export interface ReactiveLViewConsumer extends ReactiveNode { - lView: LView|null; + lView: LView; + slot: typeof REACTIVE_TEMPLATE_CONSUMER|typeof REACTIVE_HOST_BINDING_CONSUMER; + isRunning: boolean; } /** @@ -26,50 +29,40 @@ export interface ReactiveLViewConsumer extends ReactiveNode { export function getReactiveLViewConsumer( lView: LView, slot: typeof REACTIVE_TEMPLATE_CONSUMER|typeof REACTIVE_HOST_BINDING_CONSUMER): ReactiveLViewConsumer { - return lView[slot] ?? getOrCreateCurrentLViewConsumer(); + return lView[slot] ?? getOrCreateCurrentLViewConsumer(lView, slot); } -/** - * Assigns the `currentTemplateContext` to its LView's `REACTIVE_CONSUMER` slot if there are tracked - * producers. - * - * The presence of producers means that a signal was read while the consumer was the active - * consumer. - * - * If no producers are present, we do not assign the current template context. This also means we - * can just reuse the template context for the next LView. - */ -export function commitLViewConsumerIfHasProducers( - lView: LView, - slot: typeof REACTIVE_TEMPLATE_CONSUMER|typeof REACTIVE_HOST_BINDING_CONSUMER): void { - const consumer = getOrCreateCurrentLViewConsumer(); - if (!consumer.producerNode?.length) { - return; - } - - lView[slot] = currentConsumer; - consumer.lView = lView; - currentConsumer = createLViewConsumer(); -} - -const REACTIVE_LVIEW_CONSUMER_NODE: ReactiveLViewConsumer = { +const REACTIVE_LVIEW_CONSUMER_NODE: Omit = { ...REACTIVE_NODE, consumerIsAlwaysLive: true, consumerMarkedDirty: (node: ReactiveLViewConsumer) => { - (typeof ngDevMode === 'undefined' || ngDevMode) && - assertDefined( - node.lView, - 'Updating a signal during template or host binding execution is not allowed.'); - markViewDirty(node.lView!); + if (ngDevMode && node.isRunning) { + console.warn( + `Angular detected a signal being set which makes the template for this component dirty` + + ` while it's being executed, which is not currently supported and will likely result` + + ` in ExpressionChangedAfterItHasBeenChecked errors or future updates not working` + + ` entirely.`); + } + markViewDirty(node.lView); + }, + consumerOnSignalRead(this: ReactiveLViewConsumer): void { + if (currentConsumer !== this) { + return; + } + this.lView[this.slot] = currentConsumer; + currentConsumer = null; }, - lView: null, + isRunning: false, }; function createLViewConsumer(): ReactiveLViewConsumer { return Object.create(REACTIVE_LVIEW_CONSUMER_NODE); } -function getOrCreateCurrentLViewConsumer() { +function getOrCreateCurrentLViewConsumer( + lView: LView, slot: typeof REACTIVE_TEMPLATE_CONSUMER|typeof REACTIVE_HOST_BINDING_CONSUMER) { currentConsumer ??= createLViewConsumer(); + currentConsumer.lView = lView; + currentConsumer.slot = slot; return currentConsumer; } diff --git a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts index 6a6af4ddbe853..7595ee41107ff 100644 --- a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts +++ b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts @@ -7,7 +7,7 @@ */ import {NgIf} from '@angular/common'; -import {ChangeDetectionStrategy, Component, Input, signal, ViewChild} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Directive, Input, signal, ViewChild} from '@angular/core'; import {TestBed} from '@angular/core/testing'; describe('CheckAlways components', () => { @@ -370,4 +370,74 @@ describe('OnPush components with signals', () => { fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).not.toContain('blue'); }); + + it('should warn when writing to signals during change-detecting a given template, in advance()', + () => { + const counter = signal(0); + + @Directive({ + standalone: true, + selector: '[misunderstood]', + }) + class MisunderstoodDir { + ngOnInit(): void { + counter.update((c) => c + 1); + } + } + + @Component({ + selector: 'test-component', + standalone: true, + imports: [MisunderstoodDir], + template: ` + {{counter()}}
{{ 'force advance()' }} + `, + }) + class TestCmp { + counter = counter; + } + + const consoleWarnSpy = spyOn(console, 'warn').and.callThrough(); + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(false); + expect(consoleWarnSpy) + .toHaveBeenCalledWith(jasmine.stringMatching( + /will likely result in ExpressionChangedAfterItHasBeenChecked/)); + }); + + it('should warn when writing to signals during change-detecting a given template, at the end', + () => { + const counter = signal(0); + + @Directive({ + standalone: true, + selector: '[misunderstood]', + }) + class MisunderstoodDir { + ngOnInit(): void { + counter.update((c) => c + 1); + } + } + + @Component({ + selector: 'test-component', + standalone: true, + imports: [MisunderstoodDir], + template: ` + {{counter()}}
+ `, + }) + class TestCmp { + counter = counter; + } + + const consoleWarnSpy = spyOn(console, 'warn').and.callThrough(); + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(false); + expect(consoleWarnSpy) + .toHaveBeenCalledWith(jasmine.stringMatching( + /will likely result in ExpressionChangedAfterItHasBeenChecked/)); + }); }); diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 0b1f40617116a..ae10444c53209 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -716,9 +716,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "computeStaticStyling" }, @@ -782,9 +779,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -971,9 +965,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index e1a53aa84971f..09b6902a01a11 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -776,9 +776,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "computeStaticStyling" }, @@ -845,9 +842,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -1037,9 +1031,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index d55e8c90e0a19..40a252cfb8d6a 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -584,9 +584,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "computeStaticStyling" }, @@ -635,9 +632,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -806,9 +800,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 160a4be0132ed..7953c9c8a7304 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -662,9 +662,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "computeStaticStyling" }, @@ -716,9 +713,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -908,9 +902,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index d3f4a74e101b7..7ede144dc40ff 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -794,9 +794,6 @@ { "name": "collectStylingFromTAttrs" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "compose" }, @@ -869,9 +866,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -1091,9 +1085,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 9d05d577b4c82..52c2528d3d1f1 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -773,9 +773,6 @@ { "name": "collectStylingFromTAttrs" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "composeAsyncValidators" }, @@ -839,9 +836,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -1052,9 +1046,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 12b3954c8c110..1581f1b704a7a 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -449,9 +449,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "concatStringsWithSpace" }, @@ -494,9 +491,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -638,9 +632,6 @@ { "name": "getNullInjector" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index bcc5d2b5c4465..09f5b952b7eb7 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -668,9 +668,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "concatStringsWithSpace" }, @@ -713,9 +710,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -890,9 +884,6 @@ { "name": "getNullInjector" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 7fdb2e3b916be..5b0098dd17ea6 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1007,9 +1007,6 @@ { "name": "combineLatest" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "compare" }, @@ -1091,9 +1088,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNewSegmentChildren" }, @@ -1406,9 +1400,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index e8f7ca13fdccc..7d8287a8ef77f 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -524,9 +524,6 @@ { "name": "collectNativeNodesInLContainer" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "concatStringsWithSpace" }, @@ -566,9 +563,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -719,9 +713,6 @@ { "name": "getNullInjector" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index d772b7183dc86..6e88fdcd4208a 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -698,9 +698,6 @@ { "name": "collectStylingFromTAttrs" }, - { - "name": "commitLViewConsumerIfHasProducers" - }, { "name": "computeStaticStyling" }, @@ -755,9 +752,6 @@ { "name": "createLView" }, - { - "name": "createLViewConsumer" - }, { "name": "createNodeInjector" }, @@ -953,9 +947,6 @@ { "name": "getOrCreateComponentTView" }, - { - "name": "getOrCreateCurrentLViewConsumer" - }, { "name": "getOrCreateInjectable" }, From e06b73f74279d5c7f4190a2132a1fa99f74867b8 Mon Sep 17 00:00:00 2001 From: Evan Kleiner Date: Thu, 28 Sep 2023 16:36:15 -0700 Subject: [PATCH 02/52] docs: add warning for localhost proxy (#51948) PR Close #51948 --- aio/content/guide/build.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/aio/content/guide/build.md b/aio/content/guide/build.md index 3b318cd19145f..ec18d4cbeb5b6 100644 --- a/aio/content/guide/build.md +++ b/aio/content/guide/build.md @@ -388,6 +388,19 @@ If you edit the proxy configuration file, you must relaunch the `ng serve` proce +
+ +As of Node version 17, Node will not always resolve `http://localhost:` to `http://127.0.0.1:` +depending on each machine's configuration. + +If you get an `ECONNREFUSED` error using a proxy targeting a `localhost` URL, +you can fix this issue by updating the target from `http://localhost:` to `http://127.0.0.1:`. + +See [the http proxy middleware documentation](https://github.com/chimurai/http-proxy-middleware#nodejs-17-econnrefused-issue-with-ipv6-and-localhost-705) +for more information. + +
+ ### Rewrite the URL path The `pathRewrite` proxy configuration option lets you rewrite the URL path at run time. From 5e651aabe437f0539c5aeb81d8229e0911646507 Mon Sep 17 00:00:00 2001 From: Ivad Date: Tue, 24 Oct 2023 22:15:32 +0200 Subject: [PATCH 03/52] docs(docs-infra): fix typo in tap into the observable tutorial example (#52359) PR Close #52359 --- aio/content/tutorial/tour-of-heroes/toh-pt6.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aio/content/tutorial/tour-of-heroes/toh-pt6.md b/aio/content/tutorial/tour-of-heroes/toh-pt6.md index b00968f60ba4e..16883a46a34e2 100644 --- a/aio/content/tutorial/tour-of-heroes/toh-pt6.md +++ b/aio/content/tutorial/tour-of-heroes/toh-pt6.md @@ -179,7 +179,7 @@ Because each service method returns a different kind of `Observable` result, `ha ### Tap into the Observable -The `getHeros()` method taps into the flow of observable values and sends a message, using the `log()` method, to the message area at the bottom of the page. +The `getHeroes()` method taps into the flow of observable values and sends a message, using the `log()` method, to the message area at the bottom of the page. The RxJS `tap()` operator enables this ability by looking at the observable values, doing something with those values, and passing them along. The `tap()` callback doesn't access the values themselves. From edea54714d6052ca610b15e59c5db305fa8a992c Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Tue, 24 Oct 2023 17:45:08 -0400 Subject: [PATCH 04/52] docs: Add defer guide (#52380) This adds a user guide for how to use defer. PR Close #52380 --- .pullapprove.yml | 1 + aio/content/blocks/core/defer.md | 1 + aio/content/guide/defer.md | 289 +++++++++++++++++++++++++++++++ aio/content/navigation.json | 5 + 4 files changed, 296 insertions(+) create mode 100644 aio/content/guide/defer.md diff --git a/.pullapprove.yml b/.pullapprove.yml index aa2d006d570a2..530e0de5ecf05 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -341,6 +341,7 @@ groups: 'aio/content/special-elements/**/{*,.*}', 'aio/content/blocks/**/{*,.*}', 'aio/content/guide/hydration.md', + 'aio/content/guide/defer.md', 'aio/content/guide/signals.md', 'aio/content/guide/control_flow.md', 'aio/content/examples/injection-token/**/{*,.*}', diff --git a/aio/content/blocks/core/defer.md b/aio/content/blocks/core/defer.md index fcc70ea7f7ebe..0f20ae907208c 100644 --- a/aio/content/blocks/core/defer.md +++ b/aio/content/blocks/core/defer.md @@ -55,3 +55,4 @@ Configures prefetching of the defer block used in the `@defer` parameters, but d } ``` +Learn more in the [defer loading guide](guide/defer). \ No newline at end of file diff --git a/aio/content/guide/defer.md b/aio/content/guide/defer.md new file mode 100644 index 0000000000000..9d6f6c5ffa8a6 --- /dev/null +++ b/aio/content/guide/defer.md @@ -0,0 +1,289 @@ +# Deferred Loading + +## Overview + +Deferred loading is a feature that can be used in component template to defer the loading of select dependencies within that template. Those dependencies include components, directives, and pipes, and any associated CSS. To use this feature, you can declaratively wrap a section of your template in a `@defer` block which specifies the loading conditions. + +The `@defer` block supports a series of [triggers](guide/defer#triggers), [prefeching](guide/defer#prefetching), and several sub blocks used for [placeholder](guide/defer#placeholder), [loading](guide/defer#loading), and [error](guide/defer#error) state management. You can also create custom conditions with [`when`](guide/defer#when) and [`prefetch when`](guide/defer#prefetching). + +```html +@defer { + +} +``` + +## Why use `@defer`? + +`@defer` is a powerful tool that can be used to reduce the initial bundle size of your application or defer heavy components that may not ever be loaded until a later time. This should result in a faster initial load and an improvement in your Core Web Vitals (CWV) results. Deferring some of your components until later should specifically improve Largest Contentful Paint (LCP) and Time to First Byte (TTFB). + +Note: It is highly recommended that any defer loaded component that might result in layout shift once the dependencies have loaded be below the fold or otherwise not yet visible to the user. + +## Which dependencies are defer-loadable? + +In order for dependencies within a `@defer` block to be deferred, they need to meet two conditions: + +1. They must be standalone. Non-standalone dependencies cannot be deferred and will still be eagerly loaded, even inside of `@defer` blocks. + +2. They must not be directly referenced from the same file, outside of `@defer` blocks; this includes ViewChild queries. + +Transitive dependencies of the components, directives, and pipes used in the defer block can be standalone or NgModule based and will still be deferred. + +## Blocks + +`@defer` blocks have several sub blocks to allow you to gracefully handle different stages in the deferred loading process. + +### `@defer` + +The content of the main `@defer` block is the section of content that is lazily loaded. It will not be rendered initially, and all of the content will appear once the specified [trigger](guide/defer#triggers) or `when` condition is met and the dependencies have been fetched. By default, a `@defer` block is triggered when the browser state becomes [idle](guide/defer#on-idle). + +### `@placeholder` + +By default, defer blocks do not render any content before they are triggered. The `@placeholder` is an optional block that declares content to show before the defer block is triggered. This placeholder content is replaced with the main content once the loading is complete. You can use any content in the placeholder section including plain HTML, components, directives, and pipes; however keep in mind the dependencies of the placeholder block are eagerly loaded. + +Note: For the best user experience, you should always specify a `@placeholder` block. + +The `@placeholder` block accepts an optional parameter to specify the `minimum` amount of time that this placeholder should be shown. This `minimum` parameter is specified in time increments of milliseconds (ms) or seconds (s). This parameter exists to prevent fast flickering of placeholder content in the case that the deferred dependencies are fetched quickly. The `minimum` timer for the `@placeholder` block begins after the initial render of this `@placeholder` block completes. + +```html +@defer { + +} @placeholder (minimum 500ms) { +

Placeholder content

+} +``` + +Note: Certain triggers may require the presence of either a `@placeholder` or a template reference variable to function. See the [Triggers](guide/defer#triggers) section for more details. + +### `@loading` + +The `@loading` block is an optional block that allows you to declare content that will be shown during the loading of any deferred dependencies. For example, you could show a loading spinner. Similar to `@placeholder`, the dependencies of the `@loading` block are eagerly loaded. + +The `@loading` block accepts two optional parameters to specify the `minimum` amount of time that this placeholder should be shown and amount of time to wait `after` loading begins before showing the loading template. `minimum` and `after` parameters are specified in time increments of milliseconds (ms) or seconds (s). Just like `@placeholder`, these parameters exist to prevent fast flickering of content in the case that the deferred dependencies are fetched quickly. Both the `minimum` and `after` timers for the `@loading` block begins immediately after the loading has been triggered. + +```html +@defer { + +} @loading (after 100ms; minimum 1s) { + loading... +} +``` + +### `@error` + +The `@error` block allows you to declare content that will be shown if deferred loading fails. Similar to `@placeholder` and `@loading`, the dependencies of the `@error` block are eagerly loaded. The `@error` block is optional. + +```html +@defer { + +} @error { +

Failed to load the calendar

+} +``` + +## Triggers + +When a `@defer` block is triggered, it replaces placeholder content with lazily loaded content. There are two options for configuring when this swap is triggered: `on` and `when`. + + +`on` specifies a trigger condition using a trigger from the list of available triggers below. An example would be on interaction or on viewport. + +Multiple event triggers can be defined at once. For example: `on interaction; on timer(5s)` means that the defer block will be triggered if the user interacts with the placeholder, or after 5 seconds. + +Note: Multiple `on` triggers are always OR conditions. Similarly, `on` mixed with `when` conditions are also OR conditions. + +```html +@defer (on viewport; on timer(5s)) { + +} @placeholder { + +} +``` + + +`when` specifies an imperative condition as an expression that returns a boolean. When this expression becomes truthy, the placeholder is swapped with the lazily loaded content (which may be an asynchronous operation if the dependencies need to be fetched). + +Note: if the `when` condition switches back to `false`, the defer block is not reverted back to the placeholder. The swap is a one-time operation. If the content within the block should be conditionally rendered, an `if` condition can be used within the block itself. + +```html +@defer (when cond) { + +} +``` + +You could also use both `when` and `on` together in one statement, and the swap will be triggered if either condition is met. + +```html +@defer (on viewport; when cond) { + +} @placeholder { + +} +``` + +### on idle + +`idle` will trigger the deferred loading once the browser has reached an idle state (detected using the `requestIdleCallback` API under the hood). This is the default behavior with a defer block. + +### on viewport + +`viewport` would trigger the deferred block when the specified content enters the viewport using the [`IntersectionObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). This could be the placeholder content or an element reference. + +By default, the placeholder will act as the element watched for entering viewport as long as it is a single root element node. + +```html +@defer (on viewport) { + +} @placeholder { +
Calendar placeholder
+} +``` + +Alternatively, you can specify a [template reference variable](guide/glossary#template-reference-variable) in the same template as the `@defer` block as the element that is watched to enter the viewport. This variable is passed in as a parameter on the viewport trigger. + +```html +
Hello!
+ +@defer (on viewport(greeting)) { + +} +``` + +### on interaction + +`interaction` will trigger the deferred block when the user interacts with the specified element through `click` or `keydown` events. + +By default, the placeholder will act as the interaction element as long as it is a single root element node. + +```html +@defer (on interaction) { + +} @placeholder { +
Calendar placeholder
+} +``` + +Alternatively, you can specify a [template reference variable](guide/glossary#template-reference-variable) as the element that triggers interaction. This variable is passed in as a parameter on the interaction trigger. + +```html + + +@defer (on interaction(greeting)) { + +} @placeholder { +
Calendar placeholder
+} +``` + +### on hover + +`hover` triggers deferred loading when the mouse has hovered over the trigger area. Events used for this are `mouseenter` and `focusin`. + +By default, the placeholder will act as the hover element as long as it is a single root element node. + +```html +@defer (on hover) { + +} @placeholder { +
Calendar placeholder
+} +``` + +Alternatively, you can specify a [template reference variable](guide/glossary#template-reference-variable) as the hover element. This variable is passed in as a parameter on the hover trigger. + +```html +
Hello!
+ +@defer (on hover(greeting)) { + +} @placeholder { +
Calendar placeholder
+} +``` + +### on immediate + +`immediate` triggers the deferred load immediately, meaning once the client has finished rendering, the defer chunk would then start fetching right away. + +```html +@defer (on immediate) { + +} @placeholder { +
Calendar placeholder
+} +``` + +### on timer + +`timer(x)` would trigger after a specified duration. The duration is required and can be specified in `ms` or `s`. + +```html +@defer (on timer(500ms)) { + +} +``` + +## Prefetching + +`@defer` allows to specify conditions when prefetching of the dependencies should be triggered. You can use a special `prefetch` keyword. `prefetch` syntax works similarly to the main defer conditions, and accepts `when` and/or `on` to declare the trigger. + +In this case, `when` and `on` associated with defer controls when to render, and `prefetch when` and `prefetch on` controls when to fetch the resources. This enables more advanced behaviors, such as letting you start to prefetch resources before a user has actually seen or interacted with a defer block, but might interact with it soon, making the resources available faster. + +In the example below, the prefetching starts when a browser becomes idle and the contents of the block is rendered on interaction. + +```html +@defer (on interaction; prefetch on idle) { + +} @placeholder { + +} +``` + +## Testing + +Angular provides TestBed APIs to simplify the process of testing `@defer` blocks and triggering different states during testing. By default, `@defer` blocks in tests are "paused", so that you can manually transition between states. + +```typescript +it('should render a defer block in different states', async () => { + @Component({ + // ... + template: ` + @defer { + + } @loading { + Loading... + } + ` + }) + class ComponentA {} + + // Create component fixture. + const componentFixture = TestBed.createComponent(ComponentA); + + // Retrieve the list of all defer block fixtures and get the first block. + const deferBlockFixture = componentFixture.getDeferBlocks()[0]; + + // Render loading state and verify rendered output. + await deferBlockFixture.render(DeferBlockState.Loading); + expect(componentFixture.nativeElement.innerHTML).toContain('Loading'); + + // Render final state and verify the output. + await deferBlockFixture.render(DeferBlockState.Completed); + expect(componentFixture.nativeElement.innerHTML).toContain(''); +}); +``` + +## Behavior with Server-side rendering (SSR) and Static side generation (SSG) + +When rendering an application on the server (either using SSR or SSG), defer blocks always render their `@placeholder` (or nothing if a placeholder is not specified). Triggers are ignored on the server. + +## Behavior with `NgModule` + +`@defer` blocks can be used in both standalone and NgModule-based components, directives and pipes. You can use standalone and NgModule-based dependencies inside of a `@defer` block, however **only standalone components, directives, and pipes can be deferred**. The NgModule-based dependencies would be included into the eagerly loaded bundle. + +## Nested `@defer` blocks and avoiding cascading loads + +There are cases where nesting multiple `@defer` blocks may cause cascading requests. An example of this would be when a `@defer` block with an immediate trigger has a nested `@defer` block with another immediate trigger. When you have nested `@defer` blocks, make sure that an inner one has a different set of conditions, so that they don't trigger at the same time, causing cascading requests. + +## Avoiding Layout Shifts + +It is a recommended best practice to not defer components that will be visible in the user's viewport on initial load. This will negatively affect Core Web Vitals by causing an increase in cumulative layout shift (CLS). If you choose to defer components in this area, it's best to avoid `immediate`, `timer`, `viewport`, and custom `when` conditions that would cause the content to be loaded during the initial render of the page. diff --git a/aio/content/navigation.json b/aio/content/navigation.json index b947451873320..3c65ce3f79502 100644 --- a/aio/content/navigation.json +++ b/aio/content/navigation.json @@ -516,6 +516,11 @@ "title": "Hydration", "tooltip": "How to enable hydration on your server rendered applications." }, + { + "url": "guide/defer", + "title": "Deferred Loading", + "tooltip": "How to defer load components, directives, and pipes." + }, { "url": "guide/image-directive", "title": "Image optimization", From 740d46f93baf656b9ce96840fa4edad498e96947 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Tue, 24 Oct 2023 16:57:27 -0700 Subject: [PATCH 05/52] refactor(compiler): extract decorator API docs (#52389) This commit adds decorators to the extracted API docs. It makes some very hard-coded assumptions about the pattern used to declare decorators that's extremely specific to what the framework does today. PR Close #52389 --- .../src/ngtsc/docs/src/decorator_extractor.ts | 132 +++++++++++++++ .../src/ngtsc/docs/src/entities.ts | 12 ++ .../src/ngtsc/docs/src/extractor.ts | 20 ++- .../decorator_doc_extraction_spec.ts | 157 ++++++++++++++++++ 4 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts create mode 100644 packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts diff --git a/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts new file mode 100644 index 0000000000000..724527f12134b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts @@ -0,0 +1,132 @@ +/** + * @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 {extractInterface} from './class_extractor'; +import {DecoratorEntry, DecoratorType, EntryType, PropertyEntry} from './entities'; +import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor'; + +/** Extracts an API documentation entry for an Angular decorator. */ +export function extractorDecorator( + declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): DecoratorEntry { + const documentedNode = getDecoratorJsDocNode(declaration); + + const decoratorType = getDecoratorType(declaration); + if (!decoratorType) { + throw new Error(`"${declaration.name.getText()} is not a decorator."`); + } + + return { + name: declaration.name.getText(), + decoratorType: decoratorType, + entryType: EntryType.Decorator, + rawComment: extractRawJsDoc(documentedNode), + description: extractJsDocDescription(documentedNode), + jsdocTags: extractJsDocTags(documentedNode), + options: getDecoratorOptions(declaration, typeChecker), + }; +} + +/** Gets whether the given variable declaration is an Angular decorator declaration. */ +export function isDecoratorDeclaration(declaration: ts.VariableDeclaration): boolean { + return !!getDecoratorType(declaration); +} + +/** Gets whether an interface is the options interface for a decorator in the same file. */ +export function isDecoratorOptionsInterface(declaration: ts.InterfaceDeclaration): boolean { + return declaration.getSourceFile().statements.some( + s => ts.isVariableStatement(s) && + s.declarationList.declarations.some( + d => isDecoratorDeclaration(d) && d.name.getText() === declaration.name.getText())); +} + +/** Gets the type of decorator, or undefined if the declaration is not a decorator. */ +function getDecoratorType(declaration: ts.VariableDeclaration): DecoratorType|undefined { + // All Angular decorators are initialized with one of `makeDecorator`, `makePropDecorator`, + // or `makeParamDecorator`. + const initializer = declaration.initializer?.getFullText() ?? ''; + if (initializer.includes('makeDecorator')) return DecoratorType.Class; + if (initializer.includes('makePropDecorator')) return DecoratorType.Member; + if (initializer.includes('makeParamDecorator')) return DecoratorType.Parameter; + + return undefined; +} + +/** Gets the doc entry for the options object for an Angular decorator */ +function getDecoratorOptions( + declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker): PropertyEntry[] { + const name = declaration.name.getText(); + + // Every decorator has an interface with its options in the same SourceFile. + // Queries, however, are defined as a type alias pointing to an interface. + const optionsDeclaration = declaration.getSourceFile().statements.find(node => { + return (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && + node.name.getText() === name; + }); + + if (!optionsDeclaration) { + throw new Error(`Decorator "${name}" has no corresponding options interface.`); + } + + let optionsInterface: ts.InterfaceDeclaration; + if (ts.isTypeAliasDeclaration(optionsDeclaration)) { + // We hard-code the assumption that if the decorator's option type is a type alias, + // it resolves to a single interface (this is true for all query decorators at time of + // this writing). + const aliasedType = typeChecker.getTypeAtLocation((optionsDeclaration.type)); + optionsInterface = (aliasedType.getSymbol()?.getDeclarations() ?? + []).find(d => ts.isInterfaceDeclaration(d)) as ts.InterfaceDeclaration; + } else { + optionsInterface = optionsDeclaration as ts.InterfaceDeclaration; + } + + if (!optionsInterface || !ts.isInterfaceDeclaration(optionsInterface)) { + throw new Error(`Options for decorator "${name}" is not an interface.`); + } + + // Take advantage of the interface extractor to pull the appropriate member info. + // Hard code the knowledge that decorator options only have properties, never methods. + return extractInterface(optionsInterface, typeChecker).members as PropertyEntry[]; +} + +/** + * Gets the call signature node that has the decorator's public JsDoc block. + * + * Every decorator has three parts: + * - A const that has the actual decorator. + * - An interface with the same name as the const that documents the decorator's options. + * - An interface suffixed with "Decorator" that has the decorator's call signature and JsDoc block. + * + * For the description and JsDoc tags, we need the interface suffixed with "Decorator". + */ +function getDecoratorJsDocNode(declaration: ts.VariableDeclaration): ts.HasJSDoc { + const name = declaration.name.getText(); + + // Assume the existence of an interface in the same file with the same name + // suffixed with "Decorator". + const decoratorInterface = declaration.getSourceFile().statements.find(s => { + return ts.isInterfaceDeclaration(s) && s.name.getText() === `${name}Decorator`; + }); + + if (!decoratorInterface || !ts.isInterfaceDeclaration(decoratorInterface)) { + throw new Error(`No interface "${name}Decorator" found.`); + } + + // The public-facing JsDoc for each decorator is on one of its interface's call signatures. + const callSignature = decoratorInterface.members.find(node => { + // The description block lives on one of the call signatures for this interface. + return ts.isCallSignatureDeclaration(node) && extractRawJsDoc(node); + }); + + if (!callSignature || !ts.isCallSignatureDeclaration(callSignature)) { + throw new Error(`No call signature with JsDoc on "${name}Decorator"`); + } + + return callSignature; +} diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index 83e755921f38e..af824389ab49a 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -32,6 +32,12 @@ export enum MemberType { EnumItem = 'enum_item', } +export enum DecoratorType { + Class = 'class', + Member = 'member', + Parameter = 'parameter', +} + /** Informational tags applicable to class members. */ export enum MemberTags { Abstract = 'abstract', @@ -90,6 +96,12 @@ export interface EnumEntry extends DocEntry { members: EnumMemberEntry[]; } +/** Documentation entity for an Angular decorator. */ +export interface DecoratorEntry extends DocEntry { + decoratorType: DecoratorType; + options: PropertyEntry[]; +} + /** Documentation entity for an Angular directives and components. */ export interface DirectiveEntry extends ClassEntry { selector: string; diff --git a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts index 32bcda3bab6e6..4888d34280847 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts @@ -6,8 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {extractEnum} from '@angular/compiler-cli/src/ngtsc/docs/src/enum_extractor'; -import {FunctionExtractor} from '@angular/compiler-cli/src/ngtsc/docs/src/function_extractor'; import ts from 'typescript'; import {MetadataReader} from '../../metadata'; @@ -15,8 +13,11 @@ import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflectio import {extractClass, extractInterface} from './class_extractor'; import {extractConstant, isSyntheticAngularConstant} from './constant_extractor'; +import {extractorDecorator, isDecoratorDeclaration, isDecoratorOptionsInterface} from './decorator_extractor'; import {DocEntry} from './entities'; +import {extractEnum} from './enum_extractor'; import {isAngularPrivateName} from './filters'; +import {FunctionExtractor} from './function_extractor'; import {extractTypeAlias} from './type_alias_extractor'; type DeclarationWithExportName = readonly[string, ts.Declaration]; @@ -60,7 +61,7 @@ export class DocsExtractor { return extractClass(node, this.metadataReader, this.typeChecker); } - if (ts.isInterfaceDeclaration(node)) { + if (ts.isInterfaceDeclaration(node) && !isIgnoredInterface(node)) { return extractInterface(node, this.typeChecker); } @@ -70,7 +71,8 @@ export class DocsExtractor { } if (ts.isVariableDeclaration(node) && !isSyntheticAngularConstant(node)) { - return extractConstant(node, this.typeChecker); + return isDecoratorDeclaration(node) ? extractorDecorator(node, this.typeChecker) : + extractConstant(node, this.typeChecker); } if (ts.isTypeAliasDeclaration(node)) { @@ -118,3 +120,13 @@ export class DocsExtractor { ([a, declarationA], [b, declarationB]) => declarationA.pos - declarationB.pos); } } + +/** Gets whether an interface should be ignored for docs extraction. */ +function isIgnoredInterface(node: ts.InterfaceDeclaration) { + // We filter out all interfaces that end with "Decorator" because we capture their + // types as part of the main decorator entry (which are declared as constants). + // This approach to dealing with decorators is admittedly fuzzy, but this aspect of + // the framework's source code is unlikely to change. We also filter out the interfaces + // that contain the decorator options. + return node.name.getText().endsWith('Decorator') || isDecoratorOptionsInterface(node); +} diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts new file mode 100644 index 0000000000000..f3ea048413194 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts @@ -0,0 +1,157 @@ +/** + * @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 {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs'; +import {DecoratorEntry, DecoratorType, EntryType} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing'; + +import {NgtscTestEnvironment} from '../env'; + +const testFiles = loadStandardTestFiles({fakeCore: true, fakeCommon: true}); + +runInEachFileSystem(() => { + let env!: NgtscTestEnvironment; + + describe('ngtsc decorator docs extraction', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should extract class decorators that define options in an interface', () => { + env.write('index.ts', ` + export interface Component { + /** The template. */ + template: string; + } + + export interface ComponentDecorator { + /** The description. */ + (obj?: Component): any; + } + + function makeDecorator(): ComponentDecorator { return () => {}; } + + export const Component: ComponentDecorator = makeDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Component'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Class); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('template'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The template.'); + }); + + it('should extract property decorators', () => { + env.write('index.ts', ` + export interface Input { + /** The alias. */ + alias: string; + } + + export interface InputDecorator { + /** The description. */ + (alias: string): any; + } + + function makePropDecorator(): InputDecorator { return () => {}); } + + export const Input: InputDecorator = makePropDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Input'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('alias'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The alias.'); + }); + + it('should extract property decorators with a type alias', () => { + env.write('index.ts', ` + interface Query { + /** The read. */ + read: string; + } + + export type ViewChild = Query; + + export interface ViewChildDecorator { + /** The description. */ + (alias: string): any; + } + + function makePropDecorator(): ViewChildDecorator { return () => {}); } + + export const ViewChild: ViewChildDecorator = makePropDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('ViewChild'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('read'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The read.'); + }); + + it('should extract param decorators', () => { + env.write('index.ts', ` + export interface Inject { + /** The token. */ + token: string; + } + + export interface InjectDecorator { + /** The description. */ + (token: string) => any; + } + + function makePropDecorator(): InjectDecorator { return () => {}; } + + export const Inject: InjectDecorator = makeParamDecorator(); + `); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const decoratorEntry = docs[0] as DecoratorEntry; + expect(decoratorEntry.name).toBe('Inject'); + expect(decoratorEntry.description).toBe('The description.'); + expect(decoratorEntry.entryType).toBe(EntryType.Decorator); + expect(decoratorEntry.decoratorType).toBe(DecoratorType.Parameter); + + expect(decoratorEntry.options.length).toBe(1); + expect(decoratorEntry.options[0].name).toBe('token'); + expect(decoratorEntry.options[0].type).toBe('string'); + expect(decoratorEntry.options[0].description).toBe('The token.'); + }); + }); +}); From 9291ffc418c687e66089a97af5eb477af9daef46 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 25 Oct 2023 20:47:09 -0700 Subject: [PATCH 06/52] refactor(compiler): extract api docs for inherited members (#52389) This commit expands docs extraction for classes and interfaces to include inherited members. This relies on the type checker to get the _resolved_ members of the type so that the extractor doesn't need to reason about inheritance rules, which can get tricky (especially with regards to method overloads). PR Close #52389 --- .../src/ngtsc/docs/src/class_extractor.ts | 61 ++++++--- .../src/ngtsc/docs/src/entities.ts | 1 + .../class_doc_extraction_spec.ts | 117 ++++++++++++++++- .../interface_doc_extraction_spec.ts | 124 +++++++++++++++++- 4 files changed, 277 insertions(+), 26 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts index 04e6878734e36..7a51e08ecce25 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts @@ -53,7 +53,7 @@ class ClassExtractor { isAbstract: this.isAbstract(), entryType: ts.isInterfaceDeclaration(this.declaration) ? EntryType.Interface : EntryType.UndecoratedClass, - members: this.extractAllClassMembers(this.declaration), + members: this.extractAllClassMembers(), generics: extractGenerics(this.declaration), description: extractJsDocDescription(this.declaration), jsdocTags: extractJsDocTags(this.declaration), @@ -62,10 +62,10 @@ class ClassExtractor { } /** Extracts doc info for a class's members. */ - protected extractAllClassMembers(classDeclaration: ClassDeclarationLike): MemberEntry[] { + protected extractAllClassMembers(): MemberEntry[] { const members: MemberEntry[] = []; - for (const member of classDeclaration.members) { + for (const member of this.getMemberDeclarations()) { if (this.isMemberExcluded(member)) continue; const memberEntry = this.extractClassMember(member); @@ -130,9 +130,40 @@ class ClassExtractor { tags.push(MemberTags.Optional); } + if (member.parent !== this.declaration) { + tags.push(MemberTags.Inherited); + } + return tags; } + /** Gets all member declarations, including inherited members. */ + private getMemberDeclarations(): MemberElement[] { + // We rely on TypeScript to resolve all the inherited members to their + // ultimate form via `getPropertiesOfType`. This is important because child + // classes may narrow types or add method overloads. + const type = this.typeChecker.getTypeAtLocation(this.declaration); + const members = type.getProperties(); + + // While the properties of the declaration type represent the properties that exist + // on a clas *instance*, static members are properties on the class symbol itself. + const typeOfConstructor = this.typeChecker.getTypeOfSymbol(type.symbol); + const staticMembers = typeOfConstructor.getProperties(); + + const result: MemberElement[] = []; + for (const member of [...members, ...staticMembers]) { + // A member may have multiple declarations in the case of function overloads. + const memberDeclarations = member.getDeclarations() ?? []; + for (const memberDeclaration of memberDeclarations) { + if (this.isDocumentableMember(memberDeclaration)) { + result.push(memberDeclaration); + } + } + } + + return result; + } + /** Get the tags for a member that come from the declaration modifiers. */ private getMemberTagsFromModifiers(mods: Iterable): MemberTags[] { const tags: MemberTags[] = []; @@ -170,22 +201,22 @@ class ClassExtractor { private isMemberExcluded(member: MemberElement): boolean { return !member.name || !this.isDocumentableMember(member) || !!member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword) || - isAngularPrivateName(member.name.getText()); + member.name.getText() === 'prototype' || isAngularPrivateName(member.name.getText()); } /** Gets whether a class member is a method, property, or accessor. */ - private isDocumentableMember(member: MemberElement): member is MethodLike|PropertyLike { + private isDocumentableMember(member: ts.Node): member is MethodLike|PropertyLike { return this.isMethod(member) || this.isProperty(member) || ts.isAccessor(member); } /** Gets whether a member is a property. */ - private isProperty(member: MemberElement): member is PropertyLike { + private isProperty(member: ts.Node): member is PropertyLike { // Classes have declarations, interface have signatures return ts.isPropertyDeclaration(member) || ts.isPropertySignature(member); } /** Gets whether a member is a method. */ - private isMethod(member: MemberElement): member is MethodLike { + private isMethod(member: ts.Node): member is MethodLike { // Classes have declarations, interface have signatures return ts.isMethodDeclaration(member) || ts.isMethodSignature(member); } @@ -197,20 +228,14 @@ class ClassExtractor { } /** Gets whether a method is the concrete implementation for an overloaded function. */ - private isImplementationForOverload(method: MethodLike): boolean { + private isImplementationForOverload(method: MethodLike): boolean|undefined { // Method signatures (in an interface) are never implementations. if (method.kind === ts.SyntaxKind.MethodSignature) return false; - const methodsWithSameName = - this.declaration.members.filter(member => member.name?.getText() === method.name.getText()) - .sort((a, b) => a.pos - b.pos); - - // No overloads. - if (methodsWithSameName.length === 1) return false; - - // The implementation is always the last declaration, so we know this is the - // implementation if it's the last position. - return method.pos === methodsWithSameName[methodsWithSameName.length - 1].pos; + const signature = this.typeChecker.getSignatureFromDeclaration(method); + return signature && + this.typeChecker.isImplementationOfOverload( + signature.declaration as ts.SignatureDeclaration); } } diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index af824389ab49a..884cfc01d80de 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -47,6 +47,7 @@ export enum MemberTags { Optional = 'optional', Input = 'input', Output = 'output', + Inherited = 'override', } /** Documentation entity for single JsDoc tag. */ diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts index 463bf0b666a3b..30bbaecdf3657 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts @@ -73,8 +73,8 @@ runInEachFileSystem(() => { export class UserProfile { ident(value: boolean): boolean ident(value: number): number - ident(value: number|boolean): number|boolean { - return value; + ident(value: number|boolean|string): number|boolean { + return 0; } } `); @@ -207,13 +207,13 @@ runInEachFileSystem(() => { nameMember, ageMember, addressMember, - countryMember, birthdayMember, getEyeColorMember, getNameMember, getAgeMember, - getCountryMember, getBirthdayMember, + countryMember, + getCountryMember, ] = classEntry.members; // Properties @@ -381,5 +381,114 @@ runInEachFileSystem(() => { expect(genericEntry.constraint).toBeUndefined(); expect(genericEntry.default).toBeUndefined(); }); + + it('should extract inherited members', () => { + env.write('index.ts', ` + class Ancestor { + id: string; + value: string|number; + + save(value: string|number): string|number { return 0; } + } + + class Parent extends Ancestor { + name: string; + } + + export class Child extends Parent { + age: number; + value: number; + + save(value: number): number; + save(value: string|number): string|number { return 0; } + }`); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const classEntry = docs[0] as ClassEntry; + expect(classEntry.members.length).toBe(5); + + const [ageEntry, valueEntry, childSaveEntry, nameEntry, idEntry] = classEntry.members; + + expect(ageEntry.name).toBe('age'); + expect(ageEntry.memberType).toBe(MemberType.Property); + expect((ageEntry as PropertyEntry).type).toBe('number'); + expect(ageEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(valueEntry.name).toBe('value'); + expect(valueEntry.memberType).toBe(MemberType.Property); + expect((valueEntry as PropertyEntry).type).toBe('number'); + expect(valueEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(childSaveEntry.name).toBe('save'); + expect(childSaveEntry.memberType).toBe(MemberType.Method); + expect((childSaveEntry as MethodEntry).returnType).toBe('number'); + expect(childSaveEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(nameEntry.name).toBe('name'); + expect(nameEntry.memberType).toBe(MemberType.Property); + expect((nameEntry as PropertyEntry).type).toBe('string'); + expect(nameEntry.memberTags).toContain(MemberTags.Inherited); + + expect(idEntry.name).toBe('id'); + expect(idEntry.memberType).toBe(MemberType.Property); + expect((idEntry as PropertyEntry).type).toBe('string'); + expect(idEntry.memberTags).toContain(MemberTags.Inherited); + }); + + it('should extract inherited getters/setters', () => { + env.write('index.ts', ` + class Ancestor { + get name(): string { return ''; } + set name(v: string) { } + + get id(): string { return ''; } + set id(v: string) { } + + get age(): number { return 0; } + set age(v: number) { } + } + + class Parent extends Ancestor { + name: string; + } + + export class Child extends Parent { + get id(): string { return ''; } + }`); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const classEntry = docs[0] as ClassEntry; + expect(classEntry.members.length).toBe(4); + + const [idEntry, nameEntry, ageGetterEntry, ageSetterEntry] = + classEntry.members as PropertyEntry[]; + + // When the child class overrides an accessor pair with another accessor, it overrides + // *both* the getter and the setter, resulting (in this case) in just a getter. + expect(idEntry.name).toBe('id'); + expect(idEntry.memberType).toBe(MemberType.Getter); + expect((idEntry as PropertyEntry).type).toBe('string'); + expect(idEntry.memberTags).not.toContain(MemberTags.Inherited); + + // When the child class overrides an accessor with a property, the property takes precedence. + expect(nameEntry.name).toBe('name'); + expect(nameEntry.memberType).toBe(MemberType.Property); + expect(nameEntry.type).toBe('string'); + expect(nameEntry.memberTags).toContain(MemberTags.Inherited); + + expect(ageGetterEntry.name).toBe('age'); + expect(ageGetterEntry.memberType).toBe(MemberType.Getter); + expect(ageGetterEntry.type).toBe('number'); + expect(ageGetterEntry.memberTags).toContain(MemberTags.Inherited); + + expect(ageSetterEntry.name).toBe('age'); + expect(ageSetterEntry.memberType).toBe(MemberType.Setter); + expect(ageSetterEntry.type).toBe('number'); + expect(ageSetterEntry.memberTags).toContain(MemberTags.Inherited); + }); }); }); diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/interface_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/interface_doc_extraction_spec.ts index 4abb3e63b5880..b8518ddeaedab 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/interface_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/interface_doc_extraction_spec.ts @@ -7,7 +7,7 @@ */ import {DocEntry} from '@angular/compiler-cli/src/ngtsc/docs'; -import {EntryType, InterfaceEntry, MemberTags, MemberType, MethodEntry, PropertyEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; +import {ClassEntry, EntryType, InterfaceEntry, MemberTags, MemberType, MethodEntry, PropertyEntry} from '@angular/compiler-cli/src/ngtsc/docs/src/entities'; import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing'; @@ -184,12 +184,12 @@ runInEachFileSystem(() => { it('should extract getters and setters', () => { // Test getter-only, a getter + setter, and setter-only. env.write('index.ts', ` - export interface UserProfile { + export interface UserProfile { get userId(): number; - + get userName(): string; set userName(value: string); - + set isAdmin(value: boolean); } `); @@ -211,5 +211,121 @@ runInEachFileSystem(() => { expect(isAdminSetter.name).toBe('isAdmin'); expect(isAdminSetter.memberType).toBe(MemberType.Setter); }); + + it('should extract inherited members', () => { + env.write('index.ts', ` + interface Ancestor { + id: string; + value: string|number; + + save(value: string|number): string|number; + } + + interface Parent extends Ancestor { + name: string; + } + + export interface Child extends Parent { + age: number; + value: number; + + save(value: number): number; + save(value: string|number): string|number; + }`); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const interfaceEntry = docs[0] as InterfaceEntry; + expect(interfaceEntry.members.length).toBe(6); + + const [ageEntry, valueEntry, numberSaveEntry, unionSaveEntry, nameEntry, idEntry] = + interfaceEntry.members; + + expect(ageEntry.name).toBe('age'); + expect(ageEntry.memberType).toBe(MemberType.Property); + expect((ageEntry as PropertyEntry).type).toBe('number'); + expect(ageEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(valueEntry.name).toBe('value'); + expect(valueEntry.memberType).toBe(MemberType.Property); + expect((valueEntry as PropertyEntry).type).toBe('number'); + expect(valueEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(numberSaveEntry.name).toBe('save'); + expect(numberSaveEntry.memberType).toBe(MemberType.Method); + expect((numberSaveEntry as MethodEntry).returnType).toBe('number'); + expect(numberSaveEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(unionSaveEntry.name).toBe('save'); + expect(unionSaveEntry.memberType).toBe(MemberType.Method); + expect((unionSaveEntry as MethodEntry).returnType).toBe('string | number'); + expect(unionSaveEntry.memberTags).not.toContain(MemberTags.Inherited); + + expect(nameEntry.name).toBe('name'); + expect(nameEntry.memberType).toBe(MemberType.Property); + expect((nameEntry as PropertyEntry).type).toBe('string'); + expect(nameEntry.memberTags).toContain(MemberTags.Inherited); + + expect(idEntry.name).toBe('id'); + expect(idEntry.memberType).toBe(MemberType.Property); + expect((idEntry as PropertyEntry).type).toBe('string'); + expect(idEntry.memberTags).toContain(MemberTags.Inherited); + }); + + it('should extract inherited getters/setters', () => { + env.write('index.ts', ` + interface Ancestor { + get name(): string; + set name(v: string); + + get id(): string; + set id(v: string); + + get age(): number; + set age(v: number); + } + + interface Parent extends Ancestor { + name: string; + } + + export interface Child extends Parent { + get id(): string; + }`); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const interfaceEntry = docs[0] as InterfaceEntry; + expect(interfaceEntry.members.length).toBe(4); + + const [idEntry, nameEntry, ageGetterEntry, ageSetterEntry] = + interfaceEntry.members as PropertyEntry[]; + + // When the child interface overrides an accessor pair with another accessor, it overrides + // *both* the getter and the setter, resulting (in this case) in just a getter. + expect(idEntry.name).toBe('id'); + expect(idEntry.memberType).toBe(MemberType.Getter); + expect((idEntry as PropertyEntry).type).toBe('string'); + expect(idEntry.memberTags).not.toContain(MemberTags.Inherited); + + // When the child interface overrides an accessor with a property, the property takes + // precedence. + expect(nameEntry.name).toBe('name'); + expect(nameEntry.memberType).toBe(MemberType.Property); + expect(nameEntry.type).toBe('string'); + expect(nameEntry.memberTags).toContain(MemberTags.Inherited); + + expect(ageGetterEntry.name).toBe('age'); + expect(ageGetterEntry.memberType).toBe(MemberType.Getter); + expect(ageGetterEntry.type).toBe('number'); + expect(ageGetterEntry.memberTags).toContain(MemberTags.Inherited); + + expect(ageSetterEntry.name).toBe('age'); + expect(ageSetterEntry.memberType).toBe(MemberType.Setter); + expect(ageSetterEntry.type).toBe('number'); + expect(ageSetterEntry.memberTags).toContain(MemberTags.Inherited); + }); }); }); From 6b3f0bd0ea08db91247d9510436c7edc5c472515 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Thu, 26 Oct 2023 16:20:21 +0200 Subject: [PATCH 07/52] docs: control flow API reference (#52397) Adds API reference for the new built-in control flow. PR Close #52397 --- aio/content/blocks/core/for.md | 47 ++++++++++++++++++++++++++++++- aio/content/blocks/core/if.md | 21 +++++++++++--- aio/content/blocks/core/switch.md | 26 ++++++++++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/aio/content/blocks/core/for.md b/aio/content/blocks/core/for.md index c80b970097dd6..ddc4492eeeec4 100644 --- a/aio/content/blocks/core/for.md +++ b/aio/content/blocks/core/for.md @@ -1 +1,46 @@ -Placeholder content +The `@for` block repeatedly renders content of a block for each item in a collection. + +@syntax + +```html +@for (item of items; track item.name) { +
  • {{ item.name }}
  • +} @empty { +
  • There are no items.
  • +} +``` + +@description + +The `@for` block renders its content in response to changes in a collection. Collections can be any JavaScript [iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols), but there are performance advantages of using a regular `Array`. + +You can optionally include an `@empty` section immediately after the `@for` block content. The content of the `@empty` block displays when there are no items. + +

    track and objects identity

    + +The value of the `track` expression determines a key used to associate array items with the views in the DOM. Having clear indication of the item identity allows Angular to execute a minimal set of DOM operations as items are added, removed or moved in a collection. + +Loops over immutable data without `trackBy` as one of the most common causes for performance issues across Angular applications. Because of the potential for poor performance, the `track` expression is required for the `@for` loops. When in doubt, using `track $index` is a good default. + +

    `$index` and other contextual variables

    + +Inside `@for` contents, several implicit variables are always available: + +| Variable | Meaning | +| -------- | ------- | +| `$count` | Number of items in a collection iterated over | +| `$index` | Index of the current row | +| `$first` | Whether the current row is the first row | +| `$last` | Whether the current row is the last row | +| `$even` | Whether the current row index is even | +| `$odd` | Whether the current row index is odd | + +These variables are always available with these names, but can be aliased via a `let` segment: + +```html +@for (item of items; track item.id; let idx = $index, e = $even) { + Item #{{ idx }}: {{ item.name }} +} +``` + +The aliasing is especially useful in case of using nested `@for` blocks where contextual variable names could collide. diff --git a/aio/content/blocks/core/if.md b/aio/content/blocks/core/if.md index 8106f1b7a129d..8aa5c626f1017 100644 --- a/aio/content/blocks/core/if.md +++ b/aio/content/blocks/core/if.md @@ -1,12 +1,25 @@ -Control flow block used to conditionally render content in the DOM. +The `@if` block conditionally displays its content when its condition expression is truthy. @syntax ```html -@if ( ) { - +@if (a > b) { + {{a}} is greater than {{b}} +} @else if (b > a) { + {{a}} is less than {{b}} +} @else { + {{a}} is equal to {{b}} } ``` @description -This is the full description. + +Content is added and removed from the DOM based on the evaluation of conditional expressions in the `@if` and `@else` blocks. + +The built-in `@if` supports referencing of expression results to keep a solution for common coding patterns: + +```html +@if (users$ | async; as users) { + {{ users.length }} +} +``` diff --git a/aio/content/blocks/core/switch.md b/aio/content/blocks/core/switch.md index c80b970097dd6..923391124caf9 100644 --- a/aio/content/blocks/core/switch.md +++ b/aio/content/blocks/core/switch.md @@ -1 +1,25 @@ -Placeholder content +The `@switch` block is inspired by the JavaScript `switch` statement: + +@syntax + +```html +@switch (condition) { + @case (caseA) { + Case A. + } + @case (caseB) { + Case B. + } + @default { + Default case. + } +} +``` + +@description + +The `@switch` blocks displays content selected by one of the cases matching against the conditional expression. The value of the conditional expression is compared to the case expression using the `===` operator. + +The `@default` block is optional and can be omitted. If no `@case` matches the expression and there is no `@default` block, nothing is shown. + +**`@switch` does not have fallthrough**, so you do not need an equivalent to a `break` or `return` statement. From 6c580348326ba80c11bce6bcc4de0b81a96e57c8 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Thu, 26 Oct 2023 11:26:42 -0400 Subject: [PATCH 08/52] fix(migrations): Remove unhelpful parsing errors from the log (#52401) When running the control flow migration, unhelpful ICU parsing errors were being logged and creating a bunch of noise for users. PR Close #52401 --- .../schematics/ng-generate/control-flow-migration/util.ts | 6 +----- .../core/schematics/test/control_flow_migration_spec.ts | 5 +++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/util.ts b/packages/core/schematics/ng-generate/control-flow-migration/util.ts index b76e410c10b92..2d38619a6eb4a 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/util.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/util.ts @@ -107,13 +107,9 @@ export function migrateTemplate(template: string): {migrated: string|null, error // Don't migrate invalid templates. if (parsed.errors && parsed.errors.length > 0) { - for (let error of parsed.errors) { - errors.push({type: 'parse', error}); - } return {migrated: null, errors}; } - } catch (error: unknown) { - errors.push({type: 'parse', error}); + } catch { return {migrated: null, errors}; } diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index fdb709652b26f..822a659626975 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -1963,7 +1963,7 @@ describe('control flow migration', () => { @Component({ imports: [NgIf], - template: \`
    This should be hidden
    \` }) class Comp { toggle = false; @@ -1973,7 +1973,8 @@ describe('control flow migration', () => { await runMigration(); tree.readContent('/comp.ts'); - expect(warnOutput.join(' ')).toContain('WARNING: 1 errors occured during your migration'); + expect(warnOutput.join(' ')) + .toContain('IMPORTANT! This migration is in developer preview. Use with caution.'); }); }); From fa03f0a3c5e1e4562b53a3d86e98783ddd4f84cf Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Thu, 26 Oct 2023 11:33:59 -0400 Subject: [PATCH 09/52] fix(migrations): Ensure control flow migration ignores new block syntax (#52402) This fix ensures that the control flow migration does not encounter any problems when new block sytax already exists in a template. PR Close #52402 --- .../control-flow-migration/util.ts | 2 +- .../test/control_flow_migration_spec.ts | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/util.ts b/packages/core/schematics/ng-generate/control-flow-migration/util.ts index 2d38619a6eb4a..ff13d77dc49f9 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/util.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/util.ts @@ -101,7 +101,7 @@ export function migrateTemplate(template: string): {migrated: string|null, error // Allows for ICUs to be parsed. tokenizeExpansionForms: true, // Explicitly disable blocks so that their characters are treated as plain text. - tokenizeBlocks: false, + tokenizeBlocks: true, preserveLineEndings: true, }); diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index 822a659626975..79da0a927e2e0 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -2085,5 +2085,45 @@ describe('control flow migration', () => { expect(content).toContain('template: `
    shrug
    `'); }); + + it('should do nothing with already present updated control flow', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
    @if (toggle) {shrug}
    \` + }) + class Comp { + toggle = false; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + expect(content).toContain('template: `
    @if (toggle) {shrug}
    `'); + }); + + it('should migrate an ngif inside a block', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
    @if (toggle) {
    shrug
    }
    \` + }) + class Comp { + toggle = false; + show = false; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + expect(content).toContain( + 'template: `
    @if (toggle) {
    @if (show) {shrug}
    }
    `'); + }); }); }); From d122fc4b1b81d70692a8d62d5bf8d1662fe1a2ca Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Thu, 26 Oct 2023 13:23:14 -0400 Subject: [PATCH 10/52] refactor(migrations): Add optional path param for control flow migration (#52403) This adds the option to pass in a path to the control flow migration in order to run the migration against one single file. PR Close #52403 --- .../control-flow-migration/README.md | 5 +- .../control-flow-migration/index.ts | 35 ++++++-- .../control-flow-migration/schema.json | 11 ++- .../test/control_flow_migration_spec.ts | 83 ++++++++++++++++++- 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/README.md b/packages/core/schematics/ng-generate/control-flow-migration/README.md index 4f48f6ee140d1..a6f8e4a2a37f8 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/README.md +++ b/packages/core/schematics/ng-generate/control-flow-migration/README.md @@ -3,7 +3,10 @@ Angular v17 introduces a new control flow syntax. This migration replaces the existing usages of `*ngIf`, `*ngFor`, and `*ngSwitch` to their equivalent block syntax. Existing ng-templates are preserved in case they are used elsewhere in -the template. +the template. It has the following option: + +* `path` - Relative path within the project that the migration should apply to. Can be used to +migrate specific sub-directories individually. Defaults to the project root. NOTE: This is a developer preview migration diff --git a/packages/core/schematics/ng-generate/control-flow-migration/index.ts b/packages/core/schematics/ng-generate/control-flow-migration/index.ts index f03bb54909cf8..b101776575327 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/index.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/index.ts @@ -7,19 +7,25 @@ */ import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; -import {relative} from 'path'; +import {join, relative} from 'path'; +import {normalizePath} from '../../utils/change_tracker'; import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; import {AnalyzedFile, MigrateError} from './types'; import {analyze, migrateTemplate} from './util'; -export default function(): Rule { +interface Options { + path: string; +} + +export default function(options: Options): Rule { return async (tree: Tree, context: SchematicContext) => { const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); const basePath = process.cwd(); - const allPaths = [...buildPaths, ...testPaths]; + const pathToMigrate = normalizePath(join(basePath, options.path)); + const allPaths = options.path !== './' ? [...buildPaths, ...testPaths] : [pathToMigrate]; if (!allPaths.length) { throw new SchematicsException( @@ -30,7 +36,8 @@ export default function(): Rule { let errors: string[] = []; for (const tsconfigPath of allPaths) { - const migrateErrors = runControlFlowMigration(tree, tsconfigPath, basePath); + const migrateErrors = + runControlFlowMigration(tree, tsconfigPath, basePath, pathToMigrate, options); errors = [...errors, ...migrateErrors]; } @@ -43,10 +50,24 @@ export default function(): Rule { }; } -function runControlFlowMigration(tree: Tree, tsconfigPath: string, basePath: string): string[] { +function runControlFlowMigration( + tree: Tree, tsconfigPath: string, basePath: string, pathToMigrate: string, + schematicOptions: Options): string[] { + if (schematicOptions.path.startsWith('..')) { + throw new SchematicsException( + 'Cannot run control flow migration outside of the current project.'); + } + const program = createMigrationProgram(tree, tsconfigPath, basePath); - const sourceFiles = - program.getSourceFiles().filter(sourceFile => canMigrateFile(basePath, sourceFile, program)); + const sourceFiles = program.getSourceFiles().filter( + sourceFile => sourceFile.fileName.startsWith(pathToMigrate) && + canMigrateFile(basePath, sourceFile, program)); + + if (sourceFiles.length === 0) { + throw new SchematicsException(`Could not find any files to migrate under the path ${ + pathToMigrate}. Cannot run the control flow migration.`); + } + const analysis = new Map(); const migrateErrors = new Map(); diff --git a/packages/core/schematics/ng-generate/control-flow-migration/schema.json b/packages/core/schematics/ng-generate/control-flow-migration/schema.json index dc41201e2e91d..e56bd8132a6ae 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/schema.json +++ b/packages/core/schematics/ng-generate/control-flow-migration/schema.json @@ -3,5 +3,12 @@ "$id": "AngularControlFlowMigration", "title": "Angular Control Flow Migration Schema", "type": "object", - "properties": {} -} \ No newline at end of file + "properties": { + "path": { + "type": "string", + "description": "Path relative to the project root which should be migrated", + "x-prompt": "Which path in your project should be migrated?", + "default": "./" + } + } +} diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index 79da0a927e2e0..f5b84e8cc4eda 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -26,8 +26,8 @@ describe('control flow migration', () => { host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); } - function runMigration() { - return runner.runSchematic('control-flow-migration', {}, tree); + function runMigration(path: string|undefined = undefined) { + return runner.runSchematic('control-flow-migration', {path}, tree); } beforeEach(() => { @@ -64,6 +64,85 @@ describe('control flow migration', () => { shx.rm('-r', tmpDirPath); }); + describe('path', () => { + it('should throw an error if no files match the passed-in path', async () => { + let error: string|null = null; + + writeFile('dir.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + `); + + try { + await runMigration('./foo'); + } catch (e: any) { + error = e.message; + } + + expect(error).toMatch( + /Could not find any files to migrate under the path .*\/foo\. Cannot run the control flow migration/); + }); + + it('should throw an error if a path outside of the project is passed in', async () => { + let error: string|null = null; + + writeFile('dir.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + `); + + try { + await runMigration('../foo'); + } catch (e: any) { + error = e.message; + } + + expect(error).toBe('Cannot run control flow migration outside of the current project.'); + }); + + it('should only migrate the paths that were passed in', async () => { + let error: string|null = null; + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
    This should be hidden
    \` + }) + class Comp { + toggle = false; + } + `); + + writeFile('skip.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
    Show me
    \` + }) + class Comp { + show = false; + } + `); + + await runMigration('./comp.ts'); + const migratedContent = tree.readContent('/comp.ts'); + const skippedContent = tree.readContent('/skip.ts'); + + expect(migratedContent) + .toContain('template: `
    @if (toggle) {This should be hidden}
    `'); + expect(skippedContent).toContain('template: `
    Show me
    `'); + }); + }); + describe('ngIf', () => { it('should migrate an inline template', async () => { writeFile('/comp.ts', ` From 07623caefe2ec71c63d5c427c00cda59322694f2 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Thu, 26 Oct 2023 15:54:33 -0700 Subject: [PATCH 11/52] docs: update SSR docs (#52408) This commit updates the `ssr.md` file to restructure (and simplify) SSR docs. PR Close #52408 --- aio/content/examples/ssr/server.ts | 39 ++- .../examples/ssr/src/app/app.config.ts | 37 ++- aio/content/guide/ssr.md | 229 +++++------------- 3 files changed, 93 insertions(+), 212 deletions(-) diff --git a/aio/content/examples/ssr/server.ts b/aio/content/examples/ssr/server.ts index 2864f8cbb34ec..520f15e7b72e3 100644 --- a/aio/content/examples/ssr/server.ts +++ b/aio/content/examples/ssr/server.ts @@ -1,10 +1,11 @@ // #docplaster -import { APP_BASE_HREF } from '@angular/common'; -import { CommonEngine } from '@angular/ssr'; +import {APP_BASE_HREF} from '@angular/common'; +import {CommonEngine} from '@angular/ssr'; import express from 'express'; -import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + import bootstrap from './src/main.server'; // The Express app is exported so that it can be used by serverless Functions. @@ -13,42 +14,34 @@ export function app(): express.Express { const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); const indexHtml = join(serverDistFolder, 'index.server.html'); - // #docregion CommonEngine const commonEngine = new CommonEngine(); - // #enddocregion CommonEngine server.set('view engine', 'html'); server.set('views', browserDistFolder); - // #docregion data-request // TODO: implement data requests securely // Serve data from URLS that begin "/api/" server.get('/api/**', (req, res) => { res.status(404).send('data requests are not yet supported'); }); - // #enddocregion data-request - // #docregion static // Serve static files from /browser - server.get('*.*', express.static(browserDistFolder, { - maxAge: '1y' - })); - // #enddocregion static + server.get('*.*', express.static(browserDistFolder, {maxAge: '1y'})); // #docregion navigation-request // All regular routes use the Angular engine server.get('*', (req, res, next) => { - const { protocol, originalUrl, baseUrl, headers } = req; + const {protocol, originalUrl, baseUrl, headers} = req; commonEngine - .render({ - bootstrap, - documentFilePath: indexHtml, - url: `${protocol}://${headers.host}${originalUrl}`, - publicPath: browserDistFolder, - providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], - }) - .then((html) => res.send(html)) - .catch((err) => next(err)); + .render({ + bootstrap, + documentFilePath: indexHtml, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: browserDistFolder, + providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); }); // #enddocregion navigation-request diff --git a/aio/content/examples/ssr/src/app/app.config.ts b/aio/content/examples/ssr/src/app/app.config.ts index 023ea317f03bc..a062556981a8b 100644 --- a/aio/content/examples/ssr/src/app/app.config.ts +++ b/aio/content/examples/ssr/src/app/app.config.ts @@ -1,35 +1,26 @@ // #docplaster -import { importProvidersFrom } from '@angular/core'; -import { provideProtractorTestingSupport } from '@angular/platform-browser'; -import { provideClientHydration} from '@angular/platform-browser'; -import { ApplicationConfig } from '@angular/core'; -import { provideRouter } from '@angular/router'; -import { provideHttpClient, withFetch } from '@angular/common/http'; +import {provideHttpClient, withFetch} from '@angular/common/http'; +import {ApplicationConfig, importProvidersFrom} from '@angular/core'; +import {provideClientHydration, provideProtractorTestingSupport} from '@angular/platform-browser'; +import {provideRouter} from '@angular/router'; +import {HttpClientInMemoryWebApiModule} from 'angular-in-memory-web-api'; -import { routes } from './app.routes'; - -import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; -import { InMemoryDataService } from './in-memory-data.service'; +import {routes} from './app.routes'; +import {InMemoryDataService} from './in-memory-data.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), - // TODO: Enable using Fetch API when disabling `HttpClientInMemoryWebApiModule`. - provideHttpClient(/* withFetch()*/ ), - provideClientHydration(), - provideProtractorTestingSupport(), // essential for e2e testing + // TODO: Enable using Fetch API when disabling `HttpClientInMemoryWebApiModule`. + provideHttpClient(/* withFetch()*/), provideClientHydration(), + provideProtractorTestingSupport(), // essential for e2e testing - // #docregion in-mem // TODO: Remove from production apps importProvidersFrom( - // The HttpClientInMemoryWebApiModule module intercepts HTTP requests - // and returns simulated server responses. - // Remove it when a real server is ready to receive requests. - HttpClientInMemoryWebApiModule.forRoot( - InMemoryDataService, { dataEncapsulation: false } - ) - ), - // #enddocregion in-mem + // The HttpClientInMemoryWebApiModule module intercepts HTTP requests + // and returns simulated server responses. + // Remove it when a real server is ready to receive requests. + HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService, {dataEncapsulation: false})), // ... ], }; diff --git a/aio/content/guide/ssr.md b/aio/content/guide/ssr.md index dcf8c75de7c35..c87fcdb36e7e4 100644 --- a/aio/content/guide/ssr.md +++ b/aio/content/guide/ssr.md @@ -1,33 +1,26 @@ -# Server-side rendering (SSR) with Angular Universal +# Server-side rendering +Server-side rendering (SSR) is a process that involves rendering pages on the server, resulting in initial HTML content which contains initial page state. Once the HTML content is delivered to a browser, Angular initializes the application and utilizes the data contained within the HTML. -Server-Side Rendering (SSR) is a process that involves rendering pages on the server, resulting in static HTML content that mirrors the application's state for each request. Once this server-generated HTML content is produced, Angular initializes the application and utilizes the data contained within the HTML. - -The primary advantage of SSR is the enhanced speed at which applications typically render in a browser. This allows users to view the application's user interface before it becomes fully interactive. For more details, refer to the ["Why use Server-Side Rendering?"](#why-use-ssr) section below. - -If you're interested in exploring additional techniques and concepts related to SSR, you can refer to this [article](https://developers.google.com/web/updates/2019/02/rendering-on-the-web). - -To enable SSR in your Angular application, follow the steps outlined below. - - - -## Tutorial +## Why use SSR? -The [Tour of Heroes tutorial](tutorial/tour-of-heroes) is the foundation for this walkthrough. +The main advantages of SSR as compared to client-side rendering (CSR) are: -In this example, the Angular application is server rendered using based on client requests. +* **Improved performance**: SSR can improve the performance of web applications by delivering fully rendered HTML to the client, which can be parsed and displayed even before the application JavaScript is downloaded. This can be especially beneficial for users on low-bandwidth connections or mobile devices. +* **Improved Core Web Vitals**: SSR results in performance improvements that can be measured using [Core Web Vitals (CWV)](https://web.dev/learn-core-web-vitals/) statistics, such as reduced First Contentful Paint ([FCP](https://developer.chrome.com/en/docs/lighthouse/performance/first-contentful-paint/)) and Largest Contentful Paint ([LCP](https://web.dev/lcp/)), as well as Cumulative Layout Shift ([CLS](https://web.dev/cls/)). +* **Better SEO**: SSR can improve the search engine optimization (SEO) of web applications by making it easier for search engines to crawl and index the content of the application. -
    +## Enable server-side rendering -Download the finished sample code, which runs in a [Node.js® Express](https://expressjs.com) server. +To create a **new** application with SSR, run: -
    + - +ng new --ssr -### Step 1. Enable Server-Side Rendering + -To add SSR to an existing project, use the Angular CLI `ng add` command. +To add SSR to an **existing** project, use the Angular CLI `ng add` command. @@ -35,13 +28,7 @@ ng add @angular/ssr -
    - -To create an application with server-side rendering capabilities from the beginning use the [ng new --ssr](cli/new) command. - -
    - -The command updates the application code to enable SSR and adds extra files to the project structure. +These commands create and update application code to enable SSR and adds extra files to the project structure. @@ -54,166 +41,76 @@ my-app -### Step 2. Run your application in a browser - -Start the development server. - - - -ng serve - - - -After starting the dev-server, open your web browser and visit `http://localhost:4200`. -You should see the familiar Tour of Heroes dashboard page. - -Navigation using `routerLinks` works correctly because they use the built-in anchor \(``\) elements. -You can seamlessly move from the Dashboard to the Heroes page and back. -Additionally, clicking on a hero within the Dashboard page will display its Details page. - -If you throttle your network speed so that the client-side scripts take longer to download \(instructions following\), you'll notice: - -- You can't add or delete a hero -- The search box on the Dashboard page is ignored -- The _Back_ and _Save_ buttons on the Details page don't work - -The transition from the server-rendered application to the client application happens quickly on a development machine, but you should always test your applications in real-world scenarios. - -You can simulate a slower network to see the transition more clearly as follows: - -1. Open the Chrome Dev Tools and go to the Network tab. -1. Find the [Network Throttling](https://developers.google.com/web/tools/chrome-devtools/network-performance/reference#throttling) dropdown on the far right of the menu bar. -1. Try one of the "3G" speeds. - -The server-rendered application still launches quickly but the full client application might take seconds to load. - -## Why use SSR? - -Compared to a client side rendered (CSR) only application the main advantages of SSR are; - -### Improve search engine optimization (SEO) - -Google, Bing, Facebook, Twitter, and other social media sites rely on web crawlers to index your application content and make that content searchable on the web. -These web crawlers might be unable to navigate and index your highly interactive Angular application as a human user could do. +To verify that the application is server-side rendered, run it locally with `ng serve`. The initial HTML request should contain application content. -You can generate a static version of your application that is easily searchable, linkable, and navigable without JavaScript and -make a site preview available because each URL returns a fully rendered page. +## Configure server-side rendering -[Learn more about search engine optimization (SEO)](https://static.googleusercontent.com/media/www.google.com/en//webmasters/docs/search-engine-optimization-starter-guide.pdf). - -### Show the page quicker - -Displaying the page quickly can be critical for user engagement. -Pages that load faster perform better, [even with changes as small as 100ms](https://web.dev/shopping-for-speed-on-ebay). -Your application might have to launch faster to engage these users before they decide to do something else. - -With server-side rendering, the application doesn't need to wait until all JavaScript has been downloaded and executed to be displayed. In additional HTTP requests done using [`HttpClient`](api/common/http/HttpClient) are done once on the server, as there are cached. See the ["Caching data when using HttpClient"](#caching-data-when-using-httpclient) section below for additional information. - -This results in a performance improvement that can be measured using [Core Web Vitals (CWV)](https://web.dev/learn-core-web-vitals/) statistics, such as reducing the First-contentful paint ([FCP](https://developer.chrome.com/en/docs/lighthouse/performance/first-contentful-paint/)) and Largest Contentful Paint ([LCP](https://web.dev/lcp/)), as well as Cumulative Layout Shift ([CLS](https://web.dev/cls/)). - -### Caching data when using HttpClient - -When [hydration](guide/hydration) is enabled, [`HttpClient`](api/common/http/HttpClient) responses are cached while running on the server and transferring this cache to the client to avoid extra HTTP requests. After that this information is serialized and transferred to a browser as a part of the initial HTML sent from the server after server-side rendering. In a browser, [`HttpClient`](api/common/http/HttpClient) checks whether it has data in the cache and if so, reuses it instead of making a new HTTP request during initial application rendering. HttpClient stops using the cache once an application becomes [stable](api/core/ApplicationRef#isStable) while running in a browser. - -Caching is performed by default for every `HEAD` and `GET` requests. You can include `POST` or filter caching for requests by using the [`withHttpTransferCacheOptions`](/api/platform-browser/withHttpTransferCacheOptions). You can also enable or disable caching by the `transferCache` option in the [HttpClient](/api/common/http/HttpClient) [`post`](/api/common/http/HttpClient#post), [`get`](/api/common/http/HttpClient#get) and [`head`](/api/common/http/HttpClient#head) methods. - -### Rendering engine - -The `server.ts` file configures the SSR rendering engine with Node.js Express server. - -The `CommonEngine` is used to construct the rendering engine. - - - -The contructor accepts an object with the following properties: - -| Properties | Details | Default Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------- | -| _`bootstrap`_ | A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an `NgModule`. | | -| _`providers`_ | A set of platform level providers for the all request. | | -| _`enablePeformanceProfiler`_ | Enable request performance profiling data collection and printing the results in the server console. | `false` | - - -The `commonEngine.render()` function which turns a client's requests for Angular pages into server-rendered HTML pages. +The `server.ts` file configures a Node.js Express server and Angular server-side rendering. `CommonEngine` is used render an Angular application. -The function accepts an object with the following properties: - -| Properties | Details | Default Value | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------- | -| _`bootstrap`_ | A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an `NgModule`. | | -| _`providers`_ | A set of platform level providers for the current request. | | -| `url` | The url of the page to render. | | -| _`inlineCriticalCss`_ | Reduce render blocking requests by inlining critical CSS. | `true` | -| _`publicPath`_ | Base path for browser files and assets. | | -| _`document`_ | The initial DOM to use to bootstrap the server application. | | -| _`documentFilePath`_ | File path of the initial DOM to use to bootstrap the server application. | | - - -### Working around the browser APIs - -Some of the browser APIs and capabilities might be missing on the server. - -Applications cannot make use of browser-specific global objects like `window`, `document`, `navigator`, or `location`. - -Angular provides some injectable abstractions over these objects, such as [`Location`](api/common/Location) or [`DOCUMENT`](api/common/DOCUMENT); it might substitute adequately for these APIs. -If Angular doesn't provide it, it's possible to write new abstractions that delegate to the browser APIs while in the browser and to an alternative implementation while on the server \(also known as shimming\). - -Server-side applications lack access to mouse or keyboard events, which means they can't depend on user interactions such as clicking a button to display a component. -In such cases, the application needs to determine what to render solely based on the client's incoming request. -This limitation underscores the importance of making the application [routable](guide/router), using a routing mechanism to navigate and display content as needed. - - -### Using Angular Service Worker - -If you are using Angular on the server in combination with the Angular service worker, the behavior is deviates than the normal server-side rendering behavior. The initial server request will be rendered on the server as expected. However, after that initial request, subsequent requests are handled by the service worker. For subsequent requests, the `index.html` file is served statically and bypasses server-side rendering. - -### Filtering request URLs - -By default, if the application was only rendered by the server, _every_ application link clicked would arrive at the server as a navigation URL intended for the router. - -However, most server implementations have to handle requests for at least three very different kinds of resources: _data_, _application pages_, and _static files_. -Fortunately, the URLs for these different requests are easily recognized. +The `render` method of `CommonEngine` accepts an object with the following properties: -| Routing request types | Details | -| :-------------------- | :------------------------------ | -| Data request | Request URL that begins `/api` | -| Static asset | Request URL with file extension | -| App navigation | All other requests | +| Properties | Details | Default Value | +| ------------------- | ---------------------------------------------------------------------------------------- | ------------- | +| `bootstrap` | A method which returns an `NgModule` or a promise which resolves to an `ApplicationRef`. | | +| `providers` | An array of platform level providers for the current request. | | +| `url` | The url of the page to render. | | +| `inlineCriticalCss` | Whether to reduce render blocking requests by inlining critical CSS. | `true` | +| `publicPath` | Base path for browser files and assets. | | +| `document` | The initial DOM to use for bootstrapping the server application. | | +| `documentFilePath` | File path of the initial DOM to use to bootstrap the server application. | | -The `server.ts` generated by the CLI already makes these basic distinctions. -You may have to modify it to satisfy your specific application needs. +Angular CLI will scaffold an initial server implementation focused on server-side rendering your Angular application. This server can be extended to support other features such as API routes, redirects, static assets, and more. See [Express documentation](https://expressjs.com/) for more details. -#### Serving Data +## Hydration -A Node.js Express server is a pipeline of middleware that filters and processes requests one after the other. +Hydration is the process that restores the server side rendered application on the client. This includes things like reusing the server rendered DOM structures, persisting the application state, transferring application data that was retrieved already by the server, and other processes. Hydration is enabled by default when you use SSR. You can find more info in [the hydration guide](guide/hydration). -For data requests, you could configure the Node.js Express server pipeline with calls to `server.get()` as follows: +## Caching data when using HttpClient - +When SSR is enabled, [`HttpClient`](api/common/http/HttpClient) responses are cached while running on the server. After that this information is serialized and transferred to a browser as a part of the initial HTML sent from the server. In a browser, [`HttpClient`](api/common/http/HttpClient) checks whether it has data in the cache and if so, reuses it instead of making a new HTTP request during initial application rendering. `HttpClient` stops using the cache once an application becomes [stable](api/core/ApplicationRef#isStable) while running in a browser. -HELPFUL: This guide's `server.ts` _doesn't handle data requests_. It returns a `404 - Not Found` for all data API requests. +Caching is performed by default for all `HEAD` and `GET` requests. You can configure this cache by using [`withHttpTransferCacheOptions`](/api/platform-browser/withHttpTransferCacheOptions) when providing hydration. -For demonstration purposes, this tutorial intercepts all HTTP data calls from the client _before they go to the server_ and simulates the behavior of a remote data server, using Angular's "in-memory web API" demo package. +```ts +bootstrapApplication(AppComponent, { + providers: [ + provideClientHydration(withHttpTransferCacheOptions({ + includePostRequests: true + })) + ] +}); +``` -In practice, you would remove the following "in-memory web API" code from `app.config.ts`. +## Authoring server-compatible components - +Some common browser APIs and capabilities might not be available on the server. Applications cannot make use of browser-specific global objects like `window`, `document`, `navigator`, or `location` as well as certain properties of `HTMLElement`. -Then register your data API middleware in `server.ts`. +In general, code which relies on browser-specific symbols should only be executed in the browser, not on the server. This can be enforced through the [`afterRender`](api/core/afterRender) and [`afterNextRender`](api/core/afterNextRender) lifecycle hooks. These are only executed on the browser and skipped on the server. -#### Serving Static Files Safely +```ts +import { Component, ViewChild, afterNextRender } from '@angular/core'; -All static asset requests such as for JavaScript, image, and style files have a file extension (examples: `main.js`, `assets/favicon.ico`, `src/app/styles.css`). -They won't be confused with navigation or data requests if you filter for files with an extension. +@Component({ + selector: 'my-cmp', + template: `{{ ... }}`, +}) +export class MyComponent { + @ViewChild('content') contentRef: ElementRef; -To ensure that clients can only download the files that they are permitted to see, put all client-facing asset files in the `dist/my-app/browser` directory. + constructor() { + afterNextRender(() => { + // Safe to check `scrollHeight` because this will only run in the browser, not the server. + console.log('content height: ' + this.contentRef.nativeElement.scrollHeight); + }); + } +} +``` -The following Node.js Express code routes all requests for files with an extension (`*.*`) to `/dist`, and returns a `404 - NOT FOUND` error if the -file isn't found. +## Using Angular Service Worker - +If you are using Angular on the server in combination with the Angular service worker, the behavior deviates from the normal server-side rendering behavior. The initial server request will be rendered on the server as expected. However, after that initial request, subsequent requests are handled by the service worker and always client-side rendered. From b0bc25dd3ff441da4d9563332c0a4a9ad20ee904 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Fri, 27 Oct 2023 16:27:01 +0200 Subject: [PATCH 12/52] refactor(localize): mark `TargetMessage` and `MessageId` as public. (#52418) Both entries are exported in the `index.ts` PR Close #52418 --- packages/localize/src/utils/src/messages.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/localize/src/utils/src/messages.ts b/packages/localize/src/utils/src/messages.ts index 5f053bccdd0ed..8071a035d80e5 100644 --- a/packages/localize/src/utils/src/messages.ts +++ b/packages/localize/src/utils/src/messages.ts @@ -33,11 +33,15 @@ export type SourceMessage = string; * I.E. the message that indicates what will be translated to. * * Uses `{$placeholder-name}` to indicate a placeholder. + * + * @publicApi */ export type TargetMessage = string; /** * A string that uniquely identifies a message, to be used for matching translations. + * + * @publicApi */ export type MessageId = string; From e92d87ee086e155d7d358a398c3df9e98f7fa5e3 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 17 Oct 2023 17:31:07 -0700 Subject: [PATCH 13/52] test(compiler): Enable passing i18n tests (#52390) Enables a handful of i18n tests that are currently skipped, but pass if enabled. Some of them require alternate golden files because of inconsequential differences in the cost array order. PR Close #52390 --- .../es5_support/TEST_CASES.json | 3 +- .../icu_logic/TEST_CASES.json | 6 +-- .../namespaces/TEST_CASES.json | 20 +++++++-- .../namespaces/foreign_object.pipeline.js | 44 +++++++++++++++++++ ...n_object.js => foreign_object.template.js} | 0 .../namespaces/namespaced_div.pipeline.js | 38 ++++++++++++++++ ...aced_div.js => namespaced_div.template.js} | 0 .../nested_nodes/TEST_CASES.json | 32 +++++++++----- .../nested_nodes/directives.pipeline.js | 33 ++++++++++++++ .../{directives.js => directives.template.js} | 0 .../nested_nodes/nested_templates.pipeline.js | 38 ++++++++++++++++ ...plates.js => nested_templates.template.js} | 0 .../TEST_CASES.json | 3 +- .../TEST_CASES.json | 3 +- 14 files changed, 194 insertions(+), 26 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.pipeline.js rename packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/{foreign_object.js => foreign_object.template.js} (100%) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.pipeline.js rename packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/{namespaced_div.js => namespaced_div.template.js} (100%) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.pipeline.js rename packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/{directives.js => directives.template.js} (100%) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.pipeline.js rename packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/{nested_templates.js => nested_templates.template.js} (100%) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/es5_support/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/es5_support/TEST_CASES.json index 9e4c0fd23af36..b55b5610880c6 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/es5_support/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/es5_support/TEST_CASES.json @@ -16,8 +16,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json index a309cb0a1c470..0630f720916a0 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json @@ -28,8 +28,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should support ICU-only templates", @@ -43,8 +42,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate i18n instructions for icus generated outside of i18n blocks", diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/TEST_CASES.json index c54aa42fceb44..90e369bc853b3 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/TEST_CASES.json @@ -8,13 +8,19 @@ ], "expectations": [ { + "files": [ + { + "generated": "foreign_object.js", + "expected": "foreign_object.template.js", + "templatePipelineExpected": "foreign_object.pipeline.js" + } + ], "extraChecks": [ "verifyPlaceholdersIntegrity", "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should handle namespaces on i18n block containers", @@ -23,13 +29,19 @@ ], "expectations": [ { + "files": [ + { + "generated": "namespaced_div.js", + "expected": "namespaced_div.template.js", + "templatePipelineExpected": "namespaced_div.pipeline.js" + } + ], "extraChecks": [ "verifyPlaceholdersIntegrity", "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.pipeline.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.pipeline.js new file mode 100644 index 0000000000000..4f4ea3a0eadbb --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.pipeline.js @@ -0,0 +1,44 @@ + +consts: () => { + let $I18N_0$; + if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { + /** + * @suppress {msgDescriptions} + */ + const $MSG_EXTERNAL_7128002169381370313$$APP_SPEC_TS_1$ = goog.getMsg("{$startTagXhtmlDiv} Count: {$startTagXhtmlSpan}5{$closeTagXhtmlSpan}{$closeTagXhtmlDiv}", { + "closeTagXhtmlDiv": "\uFFFD/#3\uFFFD", + "closeTagXhtmlSpan": "\uFFFD/#4\uFFFD", + "startTagXhtmlDiv": "\uFFFD#3\uFFFD", + "startTagXhtmlSpan": "\uFFFD#4\uFFFD" + }, { + original_code: { + "closeTagXhtmlDiv": "", + "closeTagXhtmlSpan": "", + "startTagXhtmlDiv": "", + "startTagXhtmlSpan": "" + } + }); + $I18N_0$ = $MSG_EXTERNAL_7128002169381370313$$APP_SPEC_TS_1$; + } + else { + $I18N_0$ = $localize `${"\uFFFD#3\uFFFD"}:START_TAG__XHTML_DIV: Count: ${"\uFFFD#4\uFFFD"}:START_TAG__XHTML_SPAN:5${"\uFFFD/#4\uFFFD"}:CLOSE_TAG__XHTML_SPAN:${"\uFFFD/#3\uFFFD"}:CLOSE_TAG__XHTML_DIV:`; + } + return [ + $i18n_0$, + ["xmlns", "http://www.w3.org/2000/svg"], + ["xmlns", "http://www.w3.org/1999/xhtml"] + ]; +}, +template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵnamespaceSVG(); + $r3$.ɵɵelementStart(0, "svg", 1)(1, "foreignObject"); + $r3$.ɵɵi18nStart(2, 0); + $r3$.ɵɵnamespaceHTML(); + $r3$.ɵɵelementStart(3, "div", 2); + $r3$.ɵɵelement(4, "span"); + $r3$.ɵɵelementEnd(); + $r3$.ɵɵi18nEnd(); + $r3$.ɵɵelementEnd()(); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.template.js similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.js rename to packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/foreign_object.template.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.pipeline.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.pipeline.js new file mode 100644 index 0000000000000..9e31e57a06876 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.pipeline.js @@ -0,0 +1,38 @@ +consts: () => { + let $I18N_0$; + if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { + /** + * @suppress {msgDescriptions} + */ + const $MSG_EXTERNAL_7428861019045796010$$APP_SPEC_TS_1$ = goog.getMsg(" Count: {$startTagXhtmlSpan}5{$closeTagXhtmlSpan}", { + "closeTagXhtmlSpan": "\uFFFD/#4\uFFFD", + "startTagXhtmlSpan": "\uFFFD#4\uFFFD" + }, { + original_code: { + "closeTagXhtmlSpan": "", + "startTagXhtmlSpan": "" + } + }); + $I18N_0$ = $MSG_EXTERNAL_7428861019045796010$$APP_SPEC_TS_1$; + } + else { + $I18N_0$ = $localize ` Count: ${"\uFFFD#4\uFFFD"}:START_TAG__XHTML_SPAN:5${"\uFFFD/#4\uFFFD"}:CLOSE_TAG__XHTML_SPAN:`; + } + return [ + $i18n_0$, + ["xmlns", "http://www.w3.org/2000/svg"], + ["xmlns", "http://www.w3.org/1999/xhtml"] + ]; +}, +template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵnamespaceSVG(); + $r3$.ɵɵelementStart(0, "svg", 1)(1, "foreignObject"); + $r3$.ɵɵnamespaceHTML(); + $r3$.ɵɵelementStart(2, "div", 2); + $r3$.ɵɵi18nStart(3, 0); + $r3$.ɵɵelement(4, "span"); + $r3$.ɵɵi18nEnd(); + $r3$.ɵɵelementEnd()()(); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.template.js similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.js rename to packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/namespaces/namespaced_div.template.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/TEST_CASES.json index d2117260fd244..daf4761af6084 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/TEST_CASES.json @@ -98,8 +98,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should support interpolations with complex expressions", @@ -113,8 +112,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should handle i18n attributes with bindings in content", @@ -128,8 +126,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should handle i18n attributes with bindings and nested elements in content", @@ -143,8 +140,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should handle i18n attributes with bindings in content and element attributes", @@ -168,13 +164,19 @@ ], "expectations": [ { + "files": [ + { + "generated": "nested_templates.js", + "expected": "nested_templates.template.js", + "templatePipelineExpected": "nested_templates.pipeline.js" + } + ], "extraChecks": [ "verifyPlaceholdersIntegrity", "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should ignore i18n attributes on self-closing tags", @@ -213,13 +215,19 @@ ], "expectations": [ { + "files": [ + { + "generated": "directives.js", + "expected": "directives.template.js", + "templatePipelineExpected": "directives.pipeline.js" + } + ], "extraChecks": [ "verifyPlaceholdersIntegrity", "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate event listeners instructions before i18n ones", diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.pipeline.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.pipeline.js new file mode 100644 index 0000000000000..6ba18757e519a --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.pipeline.js @@ -0,0 +1,33 @@ +function MyComponent_div_0_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵelementStart(0, "div"); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵelement(2, "span"); + $r3$.ɵɵi18nEnd(); + $r3$.ɵɵelementEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵɵnextContext(); + $r3$.ɵɵadvance(2); + $r3$.ɵɵi18nExp($ctx_r0$.valueA); + $r3$.ɵɵi18nApply(1); + } +} +… +decls: 1, +vars: 1, +consts: () => { + __i18nMsg__('Some other content {$startTagSpan}{$interpolation}{$closeTagSpan}', [['closeTagSpan', String.raw`\uFFFD/#2\uFFFD`], ['interpolation', String.raw`\uFFFD0\uFFFD`], ['startTagSpan', String.raw`\uFFFD#2\uFFFD`]], {original_code: {'closeTagSpan': '', 'interpolation': '{{ valueA }}', 'startTagSpan': '',}}, {}) + return [ + $i18n_0$, + [__AttributeMarker.Template__, "ngIf"] + ]; +}, +template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 3, 1, "div", 1); + } + if (rf & 2) { + $r3$.ɵɵproperty("ngIf", ctx.visible); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.template.js similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.js rename to packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/directives.template.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.pipeline.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.pipeline.js new file mode 100644 index 0000000000000..32bb67c1de06d --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.pipeline.js @@ -0,0 +1,38 @@ +function MyComponent_div_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵelementStart(0, "div")(1, "div"); + $r3$.ɵɵi18nStart(2, 0); + $r3$.ɵɵelement(3, "div"); + $r3$.ɵɵpipe(4, "uppercase"); + $r3$.ɵɵi18nEnd(); + $r3$.ɵɵelementEnd()(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵɵnextContext(); + $r3$.ɵɵadvance(4); + $r3$.ɵɵi18nExp($ctx_r0$.valueA)($r3$.ɵɵpipeBind1(4, 2, $ctx_r0$.valueB)); + $r3$.ɵɵi18nApply(2); + } +} +… +decls: 3, +vars: 1, +consts: () => { + __i18nMsg__(' Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$closeTagDiv}', [['closeTagDiv', String.raw`\uFFFD/#3\uFFFD`], ['interpolation', String.raw`\uFFFD0\uFFFD`], ['interpolation_1', String.raw`\uFFFD1\uFFFD`], ['startTagDiv', String.raw`\uFFFD#3\uFFFD`]], {original_code: {'closeTagDiv': '', 'interpolation': '{{ valueA }}', 'interpolation_1': '{{ valueB | uppercase }}', 'startTagDiv': '
    '}}, {}) + return [ + $i18n_0$, + [__AttributeMarker.Template__, "ngIf"] + ]; +}, +template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵelementStart(0, "div"); + $r3$.ɵɵtext(1, " Some content "); + $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 5, 4, "div", 1); + $r3$.ɵɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵɵadvance(2); + $r3$.ɵɵproperty("ngIf", ctx.visible); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.template.js similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.js rename to packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/nested_nodes/nested_templates.template.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/self-closing_i18n_instructions/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/self-closing_i18n_instructions/TEST_CASES.json index a1833e9a262aa..7166cccbb3ffe 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/self-closing_i18n_instructions/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/self-closing_i18n_instructions/TEST_CASES.json @@ -27,8 +27,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should be generated within and blocks", diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/whitespace_preserving_mode/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/whitespace_preserving_mode/TEST_CASES.json index 396ca155a827d..86bd244c34f9e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/whitespace_preserving_mode/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/whitespace_preserving_mode/TEST_CASES.json @@ -13,8 +13,7 @@ "verifyUniqueConsts" ] } - ], - "skipForTemplatePipeline": true + ] } ] } From 99805323bb875668b883438b346e5df7f6e35428 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 23 Oct 2023 12:55:48 -0700 Subject: [PATCH 14/52] refactor(compiler): Split the i18n placeholder mega-phase (#52390) The i18n placeholder resolution phase has accumulated too much logic, making it difficult to understand. This commit refactors it into several smaller phases to make it easier to manage. I suspect this will undergo further refactoring in the near future as I work through the ICU logic. In particular `ExtractedMessageOp` feels like a bit of a grab bag of properties, and the i18n const collection phase is also starting to get quite heavy. This refactor at least feels like a good start. PR Close #52390 --- .../src/template/pipeline/ir/src/enums.ts | 28 ++ .../template/pipeline/ir/src/expression.ts | 43 +- .../template/pipeline/ir/src/ops/create.ts | 91 +++-- .../src/template/pipeline/src/emit.ts | 22 +- .../pipeline/src/phases/format_i18n_params.ts | 129 ++++++ .../src/phases/i18n_const_collection.ts | 164 +++++++- .../src/phases/i18n_message_extraction.ts | 134 +------ .../src/phases/propagate_i18n_placeholders.ts | 66 ++++ .../resolve_i18n_element_placeholders.ts | 128 ++++++ .../resolve_i18n_expression_placeholders.ts | 64 +++ .../src/phases/resolve_i18n_placeholders.ts | 366 ------------------ 11 files changed, 671 insertions(+), 564 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/phases/format_i18n_params.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/propagate_i18n_placeholders.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_i18n_element_placeholders.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_i18n_expression_placeholders.ts delete mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_i18n_placeholders.ts diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index f91b0535eb6da..2d006549e1b97 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -479,3 +479,31 @@ export enum I18nParamResolutionTime { */ Postproccessing } + +/** + * Flags that describe what an i18n param value. These determine how the value is serialized into + * the final map. + */ +export enum I18nParamValueFlags { + None = 0b0000, + + /** + * This value represtents an element tag. + */ + ElementTag = 0b001, + + /** + * This value represents a template tag. + */ + TemplateTag = 0b0010, + + /** + * This value represents the opening of a tag. + */ + OpenTag = 0b0100, + + /** + * This value represents the closing of a tag. + */ + CloseTag = 0b1000, +} diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 9e77c8f47cf5a..ed79fb01e148e 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -960,12 +960,6 @@ export function transformExpressionsInOp( op.expression = op.expression && transformExpressionsInExpression(op.expression, transform, flags); break; - case OpKind.ExtractedMessage: - op.expression = transformExpressionsInExpression(op.expression, transform, flags); - for (const statement of op.statements) { - transformExpressionsInStatement(statement, transform, flags); - } - break; case OpKind.RepeaterCreate: op.track = transformExpressionsInExpression(op.track, transform, flags); if (op.trackByFn !== null) { @@ -975,34 +969,31 @@ export function transformExpressionsInOp( case OpKind.Repeater: op.collection = transformExpressionsInExpression(op.collection, transform, flags); break; - case OpKind.I18n: - case OpKind.I18nStart: - for (const [placeholder, expression] of op.params) { - op.params.set(placeholder, transformExpressionsInExpression(expression, transform, flags)); - } - break; + case OpKind.Advance: + case OpKind.Container: + case OpKind.ContainerEnd: + case OpKind.ContainerStart: case OpKind.Defer: - case OpKind.DeferSecondaryBlock: case OpKind.DeferOn: - case OpKind.Projection: - case OpKind.ProjectionDef: + case OpKind.DeferSecondaryBlock: + case OpKind.DisableBindings: case OpKind.Element: - case OpKind.ElementStart: case OpKind.ElementEnd: - case OpKind.I18nEnd: - case OpKind.Container: - case OpKind.ContainerStart: - case OpKind.ContainerEnd: - case OpKind.Template: - case OpKind.DisableBindings: + case OpKind.ElementStart: case OpKind.EnableBindings: - case OpKind.Text: - case OpKind.Pipe: - case OpKind.Advance: - case OpKind.Namespace: + case OpKind.ExtractedMessage: + case OpKind.I18n: case OpKind.I18nApply: + case OpKind.I18nEnd: + case OpKind.I18nStart: case OpKind.Icu: case OpKind.IcuUpdate: + case OpKind.Namespace: + case OpKind.Pipe: + case OpKind.Projection: + case OpKind.ProjectionDef: + case OpKind.Template: + case OpKind.Text: // These operations contain no expressions. break; default: diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 46aee8ce42341..e1af45a63d8f9 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -9,7 +9,7 @@ import * as i18n from '../../../../../i18n/i18n_ast'; import * as o from '../../../../../output/output_ast'; import {ParseSourceSpan} from '../../../../../parse_util'; -import {BindingKind, DeferSecondaryKind, OpKind} from '../enums'; +import {BindingKind, DeferSecondaryKind, I18nParamValueFlags, OpKind} from '../enums'; import {Op, OpList, XrefId} from '../operations'; import {ConsumesSlotOpTrait, HasConstTrait, TRAIT_CONSUMES_SLOT, TRAIT_HAS_CONST, TRAIT_USES_SLOT_INDEX, UsesSlotIndexTrait} from '../traits'; @@ -739,6 +739,27 @@ export function createDeferOnOp(xref: XrefId, sourceSpan: ParseSourceSpan): Defe }; } +/** + * Represents a single value in an i18n param map. Each placeholder in the map may have multiple of + * these values associated with it. + */ +export interface I18nParamValue { + /** + * The value. + */ + value: string|number; + + /** + * The sub-template index associated with the value. + */ + subTemplateIndex: number|null; + + /** + * Flags associated with the value. + */ + flags: I18nParamValueFlags; +} + /** * Represents an i18n message that has been extracted for inclusion in the consts array. */ @@ -751,26 +772,61 @@ export interface ExtractedMessageOp extends Op { owner: XrefId; /** - * The message expression. + * The i18n message represented by this op. + */ + message: i18n.Message; + + /** + * Whether this op represents a root message (as opposed to a partial message for a sub-template + * in a root message). + */ + isRoot: boolean; + + /** + * The param map, with placeholders represented as an array of value objects for easy + * manipulation. + */ + params: Map; + + /** + * The post-processing param map, with placeholders represented as an array of value objects for + * easy manipulation. + */ + postprocessingParams: Map; + + /** + * Whether this message needs post-processing. + */ + needsPostprocessing: boolean; + + /** + * The param map, with placeholders represented as an `Expression` to facilitate extraction to the + * const arry. */ - expression: o.Expression; + formattedParams: Map|null; /** - * The statements to construct the message. + * The post-processing param map, with placeholders represented as an `Expression` to facilitate + * extraction to the const arry. */ - statements: o.Statement[]; + formattedPostprocessingParams: Map|null; } /** * Create an `ExtractedMessageOp`. */ export function createExtractedMessageOp( - owner: XrefId, expression: o.Expression, statements: o.Statement[]): ExtractedMessageOp { + owner: XrefId, message: i18n.Message, isRoot: boolean): ExtractedMessageOp { return { kind: OpKind.ExtractedMessage, owner, - expression, - statements, + message, + isRoot, + params: new Map(), + postprocessingParams: new Map(), + needsPostprocessing: false, + formattedParams: null, + formattedPostprocessingParams: null, ...NEW_OP, }; } @@ -794,17 +850,6 @@ export interface I18nOpBase extends Op, ConsumesSlotOpTrait { */ message: i18n.Message; - /** - * Map of values to use for named placeholders in the i18n message. (Resolved at message creation) - */ - params: Map; - - /** - * Map of values to use for named placeholders in the i18n message. (Resolved during - * post-porcessing) - */ - postprocessingParams: Map; - /** * The index in the consts array where the message i18n message is stored. */ @@ -814,11 +859,6 @@ export interface I18nOpBase extends Op, ConsumesSlotOpTrait { * The index of this sub-block in the i18n message. For a root i18n block, this is null. */ subTemplateIndex: number|null; - - /** - * Whether the i18n message requires postprocessing. - */ - needsPostprocessing: boolean; } /** @@ -844,11 +884,8 @@ export function createI18nStartOp(xref: XrefId, message: i18n.Message, root?: Xr xref, root: root ?? xref, message, - params: new Map(), - postprocessingParams: new Map(), messageIndex: null, subTemplateIndex: null, - needsPostprocessing: false, ...NEW_OP, ...TRAIT_CONSUMES_SLOT, }; diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index 4d85ab74db921..a7697062df55c 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -16,15 +16,15 @@ import {CompilationJob, CompilationJobKind as Kind, type ComponentCompilationJob import {phaseFindAnyCasts} from './phases/any_cast'; import {phaseApplyI18nExpressions} from './phases/apply_i18n_expressions'; import {phaseAssignI18nSlotDependencies} from './phases/assign_i18n_slot_dependencies'; -import {phaseCollapseSingletonInterpolations} from './phases/collapse_singleton_interpolations'; import {phaseAttributeExtraction} from './phases/attribute_extraction'; import {phaseBindingSpecialization} from './phases/binding_specialization'; import {phaseChaining} from './phases/chaining'; +import {phaseCollapseSingletonInterpolations} from './phases/collapse_singleton_interpolations'; import {phaseConditionals} from './phases/conditionals'; import {phaseConstCollection} from './phases/const_collection'; import {phaseEmptyElements} from './phases/empty_elements'; import {phaseExpandSafeReads} from './phases/expand_safe_reads'; -import {phaseRepeaterDerivedVars} from './phases/repeater_derived_vars'; +import {phaseFormatI18nParams} from './phases/format_i18n_params'; import {phaseGenerateAdvance} from './phases/generate_advance'; import {phaseGenerateProjectionDef} from './phases/generate_projection_def'; import {phaseGenerateVariables} from './phases/generate_variables'; @@ -47,25 +47,28 @@ import {phaseRemoveContentSelectors} from './phases/phase_remove_content_selecto import {phasePipeCreation} from './phases/pipe_creation'; import {phasePipeVariadic} from './phases/pipe_variadic'; import {phasePropagateI18nBlocks} from './phases/propagate_i18n_blocks'; +import {phasePropagateI18nPlaceholders} from './phases/propagate_i18n_placeholders'; import {phasePureFunctionExtraction} from './phases/pure_function_extraction'; import {phasePureLiteralStructures} from './phases/pure_literal_structures'; import {phaseReify} from './phases/reify'; import {phaseRemoveEmptyBindings} from './phases/remove_empty_bindings'; +import {phaseRepeaterDerivedVars} from './phases/repeater_derived_vars'; import {phaseResolveContexts} from './phases/resolve_contexts'; import {phaseResolveDollarEvent} from './phases/resolve_dollar_event'; -import {phaseResolveI18nPlaceholders} from './phases/resolve_i18n_placeholders'; +import {phaseResolveI18nElementPlaceholders} from './phases/resolve_i18n_element_placeholders'; +import {phaseResolveI18nExpressionPlaceholders} from './phases/resolve_i18n_expression_placeholders'; import {phaseResolveNames} from './phases/resolve_names'; import {phaseResolveSanitizers} from './phases/resolve_sanitizers'; import {phaseSaveRestoreView} from './phases/save_restore_view'; import {phaseSlotAllocation} from './phases/slot_allocation'; import {phaseStyleBindingSpecialization} from './phases/style_binding_specialization'; import {phaseTemporaryVariables} from './phases/temporary_variables'; +import {phaseTrackFnGeneration} from './phases/track_fn_generation'; +import {phaseTrackFnOptimization} from './phases/track_fn_optimization'; +import {phaseTrackVariables} from './phases/track_variables'; import {phaseVarCounting} from './phases/var_counting'; import {phaseVariableOptimization} from './phases/variable_optimization'; import {phaseWrapIcus} from './phases/wrap_icus'; -import {phaseTrackVariables} from './phases/track_variables'; -import {phaseTrackFnGeneration} from './phases/track_fn_generation'; -import {phaseTrackFnOptimization} from './phases/track_fn_optimization'; type Phase = { fn: (job: CompilationJob) => void; kind: Kind.Both | Kind.Host | Kind.Tmpl; @@ -114,9 +117,12 @@ const phases: Phase[] = [ {kind: Kind.Both, fn: phaseExpandSafeReads}, {kind: Kind.Both, fn: phaseTemporaryVariables}, {kind: Kind.Tmpl, fn: phaseSlotAllocation}, - {kind: Kind.Tmpl, fn: phaseResolveI18nPlaceholders}, - {kind: Kind.Tmpl, fn: phaseTrackFnGeneration}, {kind: Kind.Tmpl, fn: phaseI18nMessageExtraction}, + {kind: Kind.Tmpl, fn: phaseResolveI18nElementPlaceholders}, + {kind: Kind.Tmpl, fn: phaseResolveI18nExpressionPlaceholders}, + {kind: Kind.Tmpl, fn: phasePropagateI18nPlaceholders}, + {kind: Kind.Tmpl, fn: phaseFormatI18nParams}, + {kind: Kind.Tmpl, fn: phaseTrackFnGeneration}, {kind: Kind.Tmpl, fn: phaseI18nConstCollection}, {kind: Kind.Tmpl, fn: phaseConstTraitCollection}, {kind: Kind.Both, fn: phaseConstCollection}, diff --git a/packages/compiler/src/template/pipeline/src/phases/format_i18n_params.ts b/packages/compiler/src/template/pipeline/src/phases/format_i18n_params.ts new file mode 100644 index 0000000000000..b6cec8634732e --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/format_i18n_params.ts @@ -0,0 +1,129 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import {ComponentCompilationJob} from '../compilation'; + +/** + * The escape sequence used indicate message param values. + */ +const ESCAPE = '\uFFFD'; + +/** + * Marker used to indicate an element tag. + */ +const ELEMENT_MARKER = '#'; + +/** + * Marker used to indicate a template tag. + */ +const TEMPLATE_MARKER = '*'; + +/** + * Marker used to indicate closing of an element or template tag. + */ +const TAG_CLOSE_MARKER = '/'; + +/** + * Marker used to indicate the sub-template context. + */ +const CONTEXT_MARKER = ':'; + +/** + * Marker used to indicate the start of a list of values. + */ +const LIST_START_MARKER = '['; + +/** + * Marker used to indicate the end of a list of values. + */ +const LIST_END_MARKER = ']'; + +/** + * Delimiter used to separate multiple values in a list. + */ +const LIST_DELIMITER = '|'; + +/** + * Formats the param maps on extracted message ops into a maps of `Expression` objects that can be + * used in the final output. + */ +export function phaseFormatI18nParams(job: ComponentCompilationJob): void { + for (const unit of job.units) { + for (const op of unit.create) { + if (op.kind === ir.OpKind.ExtractedMessage) { + if (op.isRoot) { + op.formattedParams = formatParams(op.params); + op.formattedPostprocessingParams = formatParams(op.postprocessingParams); + + // The message will need post-processing if there are any post-processing params, or if + // there are any normal params that have multiple values + op.needsPostprocessing = op.postprocessingParams.size > 0; + for (const [param, values] of op.params) { + if (values.length > 1) { + op.needsPostprocessing = true; + } + } + } + } + } + } +} + +/** + * Formats a map of `I18nParamValue[]` values into a map of `Expression` values. + */ +function formatParams(params: Map): Map { + const result = new Map(); + for (const [placeholder, placeholderValues] of [...params].sort()) { + const serializedValues = formatParamValues(placeholderValues); + if (serializedValues !== null) { + result.set(placeholder, o.literal(formatParamValues(placeholderValues))); + } + } + return result; +} + +/** + * Formats an `I18nParamValue[]` into a string (or null for empty array). + */ +function formatParamValues(values: ir.I18nParamValue[]): string|null { + if (values.length === 0) { + return null; + } + const serializedValues = values.map(value => formatValue(value)); + return serializedValues.length === 1 ? + serializedValues[0] : + `${LIST_START_MARKER}${serializedValues.join(LIST_DELIMITER)}${LIST_END_MARKER}`; +} + +/** + * Formats a single `I18nParamValue` into a string + */ +function formatValue(value: ir.I18nParamValue): string { + let tagMarker = ''; + let closeMarker = ''; + if (value.flags & ir.I18nParamValueFlags.ElementTag) { + tagMarker = ELEMENT_MARKER; + } else if (value.flags & ir.I18nParamValueFlags.TemplateTag) { + tagMarker = TEMPLATE_MARKER; + } + if (tagMarker !== '') { + closeMarker = value.flags & ir.I18nParamValueFlags.CloseTag ? TAG_CLOSE_MARKER : ''; + } + const context = + value.subTemplateIndex === null ? '' : `${CONTEXT_MARKER}${value.subTemplateIndex}`; + // Self-closing tags use a special form that concatenates the start and close tag values. + if ((value.flags & ir.I18nParamValueFlags.OpenTag) && + (value.flags & ir.I18nParamValueFlags.CloseTag)) { + return `${ESCAPE}${tagMarker}${value.value}${context}${ESCAPE}${ESCAPE}${closeMarker}${ + tagMarker}${value.value}${context}${ESCAPE}`; + } + return `${ESCAPE}${closeMarker}${tagMarker}${value.value}${context}${ESCAPE}`; +} diff --git a/packages/compiler/src/template/pipeline/src/phases/i18n_const_collection.ts b/packages/compiler/src/template/pipeline/src/phases/i18n_const_collection.ts index f42ae4c7fd2c2..c499c140daaf3 100644 --- a/packages/compiler/src/template/pipeline/src/phases/i18n_const_collection.ts +++ b/packages/compiler/src/template/pipeline/src/phases/i18n_const_collection.ts @@ -6,20 +6,69 @@ * found in the LICENSE file at https://angular.io/license */ +import {type ConstantPool} from '../../../../constant_pool'; +import * as i18n from '../../../../i18n/i18n_ast'; +import * as o from '../../../../output/output_ast'; +import {sanitizeIdentifier} from '../../../../parse_util'; +import {Identifiers} from '../../../../render3/r3_identifiers'; +import {createGoogleGetMsgStatements} from '../../../../render3/view/i18n/get_msg_utils'; +import {createLocalizeStatements} from '../../../../render3/view/i18n/localize_utils'; +import {declareI18nVariable, formatI18nPlaceholderNamesInMap, getTranslationConstPrefix} from '../../../../render3/view/i18n/util'; import * as ir from '../../ir'; import {ComponentCompilationJob} from '../compilation'; +/** Name of the global variable that is used to determine if we use Closure translations or not */ +const NG_I18N_CLOSURE_MODE = 'ngI18nClosureMode'; + +/** + * Prefix for non-`goog.getMsg` i18n-related vars. + * Note: the prefix uses lowercase characters intentionally due to a Closure behavior that + * considers variables like `I18N_0` as constants and throws an error when their value changes. + */ +const TRANSLATION_VAR_PREFIX = 'i18n_'; + /** * Lifts i18n properties into the consts array. */ export function phaseI18nConstCollection(job: ComponentCompilationJob): void { - // Serialize the extracted messages into the const array. - // TODO: Use `Map` instead of object. - const messageConstIndices: {[id: ir.XrefId]: ir.ConstIndex} = {}; + const fileBasedI18nSuffix = + job.relativeContextFilePath.replace(/[^A-Za-z0-9]/g, '_').toUpperCase() + '_'; + const messageConstIndices = new Map(); + for (const unit of job.units) { for (const op of unit.create) { if (op.kind === ir.OpKind.ExtractedMessage) { - messageConstIndices[op.owner] = job.addConst(op.expression, op.statements); + // Serialize the extracted root messages into the const array. + if (op.isRoot) { + assertAllParamsResolved(op); + + const mainVar = o.variable(job.pool.uniqueName(TRANSLATION_VAR_PREFIX)); + // Closure Compiler requires const names to start with `MSG_` but disallows any other + // const to start with `MSG_`. We define a variable starting with `MSG_` just for the + // `goog.getMsg` call + const closureVar = i18nGenerateClosureVar( + job.pool, op.message.id, fileBasedI18nSuffix, job.i18nUseExternalIds); + let transformFn = undefined; + + // If nescessary, add a post-processing step and resolve any placeholder params that are + // set in post-processing. + if (op.needsPostprocessing) { + const extraTransformFnParams: o.Expression[] = []; + if (op.formattedPostprocessingParams.size > 0) { + extraTransformFnParams.push(o.literalMap([...op.formattedPostprocessingParams].map( + ([key, value]) => ({key, value, quoted: true})))); + } + transformFn = (expr: o.ReadVarExpr) => + o.importExpr(Identifiers.i18nPostprocess).callFn([expr, ...extraTransformFnParams]); + } + + const statements = getTranslationDeclStmts( + op.message, mainVar, closureVar, op.formattedParams!, transformFn); + + messageConstIndices.set(op.owner, job.addConst(mainVar, statements)); + } + + // Remove the extracted messages from the IR now that they have been collected. ir.OpList.remove(op); } } @@ -29,8 +78,113 @@ export function phaseI18nConstCollection(job: ComponentCompilationJob): void { for (const unit of job.units) { for (const op of unit.create) { if (op.kind === ir.OpKind.I18nStart) { - op.messageIndex = messageConstIndices[op.root]; + op.messageIndex = messageConstIndices.get(op.root)!; } } } } + +/** + * Generate statements that define a given translation message. + * + * ``` + * var I18N_1; + * if (typeof ngI18nClosureMode !== undefined && ngI18nClosureMode) { + * var MSG_EXTERNAL_XXX = goog.getMsg( + * "Some message with {$interpolation}!", + * { "interpolation": "\uFFFD0\uFFFD" } + * ); + * I18N_1 = MSG_EXTERNAL_XXX; + * } + * else { + * I18N_1 = $localize`Some message with ${'\uFFFD0\uFFFD'}!`; + * } + * ``` + * + * @param message The original i18n AST message node + * @param variable The variable that will be assigned the translation, e.g. `I18N_1`. + * @param closureVar The variable for Closure `goog.getMsg` calls, e.g. `MSG_EXTERNAL_XXX`. + * @param params Object mapping placeholder names to their values (e.g. + * `{ "interpolation": "\uFFFD0\uFFFD" }`). + * @param transformFn Optional transformation function that will be applied to the translation (e.g. + * post-processing). + * @returns An array of statements that defined a given translation. + */ +function getTranslationDeclStmts( + message: i18n.Message, variable: o.ReadVarExpr, closureVar: o.ReadVarExpr, + params: Map, + transformFn?: (raw: o.ReadVarExpr) => o.Expression): o.Statement[] { + const paramsObject = Object.fromEntries(params); + const statements: o.Statement[] = [ + declareI18nVariable(variable), + o.ifStmt( + createClosureModeGuard(), + createGoogleGetMsgStatements(variable, message, closureVar, paramsObject), + createLocalizeStatements( + variable, message, + formatI18nPlaceholderNamesInMap(paramsObject, /* useCamelCase */ false))), + ]; + + if (transformFn) { + statements.push(new o.ExpressionStatement(variable.set(transformFn(variable)))); + } + + return statements; +} + +/** + * Create the expression that will be used to guard the closure mode block + * It is equivalent to: + * + * ``` + * typeof ngI18nClosureMode !== undefined && ngI18nClosureMode + * ``` + */ +function createClosureModeGuard(): o.BinaryOperatorExpr { + return o.typeofExpr(o.variable(NG_I18N_CLOSURE_MODE)) + .notIdentical(o.literal('undefined', o.STRING_TYPE)) + .and(o.variable(NG_I18N_CLOSURE_MODE)); +} + +/** + * Generates vars with Closure-specific names for i18n blocks (i.e. `MSG_XXX`). + */ +function i18nGenerateClosureVar( + pool: ConstantPool, messageId: string, fileBasedI18nSuffix: string, + useExternalIds: boolean): o.ReadVarExpr { + let name: string; + const suffix = fileBasedI18nSuffix; + if (useExternalIds) { + const prefix = getTranslationConstPrefix(`EXTERNAL_`); + const uniqueSuffix = pool.uniqueName(suffix); + name = `${prefix}${sanitizeIdentifier(messageId)}$$${uniqueSuffix}`; + } else { + const prefix = getTranslationConstPrefix(suffix); + name = pool.uniqueName(prefix); + } + return o.variable(name); +} + +/** + * Asserts that all of the message's placeholders have values. + */ +function assertAllParamsResolved(op: ir.ExtractedMessageOp): asserts op is ir.ExtractedMessageOp&{ + formattedParams: Map, + formattedPostprocessingParams: Map, +} { + if (op.formattedParams === null || op.formattedPostprocessingParams === null) { + throw Error('Params should have been formatted.'); + } + for (const placeholder in op.message.placeholders) { + if (!op.formattedParams.has(placeholder) && + !op.formattedPostprocessingParams.has(placeholder)) { + throw Error(`Failed to resolve i18n placeholder: ${placeholder}`); + } + } + for (const placeholder in op.message.placeholderToMessage) { + if (!op.formattedParams.has(placeholder) && + !op.formattedPostprocessingParams.has(placeholder)) { + throw Error(`Failed to resolve i18n message placeholder: ${placeholder}`); + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/i18n_message_extraction.ts b/packages/compiler/src/template/pipeline/src/phases/i18n_message_extraction.ts index 7894f35c26b8a..88e9b5f750d60 100644 --- a/packages/compiler/src/template/pipeline/src/phases/i18n_message_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/i18n_message_extraction.ts @@ -6,146 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {type ConstantPool} from '../../../../constant_pool'; -import * as i18n from '../../../../i18n/i18n_ast'; -import * as o from '../../../../output/output_ast'; -import {sanitizeIdentifier} from '../../../../parse_util'; -import {Identifiers} from '../../../../render3/r3_identifiers'; -import {createGoogleGetMsgStatements} from '../../../../render3/view/i18n/get_msg_utils'; -import {createLocalizeStatements} from '../../../../render3/view/i18n/localize_utils'; -import {declareI18nVariable, formatI18nPlaceholderNamesInMap, getTranslationConstPrefix} from '../../../../render3/view/i18n/util'; import * as ir from '../../ir'; import {ComponentCompilationJob} from '../compilation'; - -/** Name of the global variable that is used to determine if we use Closure translations or not */ -const NG_I18N_CLOSURE_MODE = 'ngI18nClosureMode'; - -/** - * Prefix for non-`goog.getMsg` i18n-related vars. - * Note: the prefix uses lowercase characters intentionally due to a Closure behavior that - * considers variables like `I18N_0` as constants and throws an error when their value changes. - */ -export const TRANSLATION_VAR_PREFIX = 'i18n_'; - -/** Extracts i18n messages into the consts array. */ +/** Extracts i18n messages into their own op. */ export function phaseI18nMessageExtraction(job: ComponentCompilationJob): void { - const fileBasedI18nSuffix = - job.relativeContextFilePath.replace(/[^A-Za-z0-9]/g, '_').toUpperCase() + '_'; for (const unit of job.units) { for (const op of unit.create) { if (op.kind === ir.OpKind.I18nStart) { - // Only extract messages from root i18n ops, not sub-template ones. - if (op.xref === op.root) { - // Sort the params map to match the ordering in TemplateDefinitionBuilder. - const params = new Map([...op.params.entries()].sort()); - - const mainVar = o.variable(job.pool.uniqueName(TRANSLATION_VAR_PREFIX)); - // Closure Compiler requires const names to start with `MSG_` but disallows any other - // const to start with `MSG_`. We define a variable starting with `MSG_` just for the - // `goog.getMsg` call - const closureVar = i18nGenerateClosureVar( - job.pool, op.message.id, fileBasedI18nSuffix, job.i18nUseExternalIds); - let transformFn = undefined; - - // If nescessary, add a post-processing step and resolve any placeholder params that are - // set in post-processing. - if (op.needsPostprocessing) { - const extraTransformFnParams: o.Expression[] = []; - if (op.postprocessingParams.size > 0) { - extraTransformFnParams.push(o.literalMap([...op.postprocessingParams.entries()].map( - ([key, value]) => ({key, value, quoted: true})))); - } - transformFn = (expr: o.ReadVarExpr) => - o.importExpr(Identifiers.i18nPostprocess).callFn([expr, ...extraTransformFnParams]); - } - - const statements = - getTranslationDeclStmts(op.message, mainVar, closureVar, params, transformFn); - unit.create.push(ir.createExtractedMessageOp(op.xref, mainVar, statements)); - } + unit.create.push(ir.createExtractedMessageOp(op.xref, op.message, op.xref === op.root)); } } } } - -/** - * Generate statements that define a given translation message. - * - * ``` - * var I18N_1; - * if (typeof ngI18nClosureMode !== undefined && ngI18nClosureMode) { - * var MSG_EXTERNAL_XXX = goog.getMsg( - * "Some message with {$interpolation}!", - * { "interpolation": "\uFFFD0\uFFFD" } - * ); - * I18N_1 = MSG_EXTERNAL_XXX; - * } - * else { - * I18N_1 = $localize`Some message with ${'\uFFFD0\uFFFD'}!`; - * } - * ``` - * - * @param message The original i18n AST message node - * @param variable The variable that will be assigned the translation, e.g. `I18N_1`. - * @param closureVar The variable for Closure `goog.getMsg` calls, e.g. `MSG_EXTERNAL_XXX`. - * @param params Object mapping placeholder names to their values (e.g. - * `{ "interpolation": "\uFFFD0\uFFFD" }`). - * @param transformFn Optional transformation function that will be applied to the translation (e.g. - * post-processing). - * @returns An array of statements that defined a given translation. - */ -function getTranslationDeclStmts( - message: i18n.Message, variable: o.ReadVarExpr, closureVar: o.ReadVarExpr, - params: Map, - transformFn?: (raw: o.ReadVarExpr) => o.Expression): o.Statement[] { - const paramsObject = Object.fromEntries(params); - const statements: o.Statement[] = [ - declareI18nVariable(variable), - o.ifStmt( - createClosureModeGuard(), - createGoogleGetMsgStatements(variable, message, closureVar, paramsObject), - createLocalizeStatements( - variable, message, - formatI18nPlaceholderNamesInMap(paramsObject, /* useCamelCase */ false))), - ]; - - if (transformFn) { - statements.push(new o.ExpressionStatement(variable.set(transformFn(variable)))); - } - - return statements; -} - -/** - * Create the expression that will be used to guard the closure mode block - * It is equivalent to: - * - * ``` - * typeof ngI18nClosureMode !== undefined && ngI18nClosureMode - * ``` - */ -function createClosureModeGuard(): o.BinaryOperatorExpr { - return o.typeofExpr(o.variable(NG_I18N_CLOSURE_MODE)) - .notIdentical(o.literal('undefined', o.STRING_TYPE)) - .and(o.variable(NG_I18N_CLOSURE_MODE)); -} - -/** - * Generates vars with Closure-specific names for i18n blocks (i.e. `MSG_XXX`). - */ -function i18nGenerateClosureVar( - pool: ConstantPool, messageId: string, fileBasedI18nSuffix: string, - useExternalIds: boolean): o.ReadVarExpr { - let name: string; - const suffix = fileBasedI18nSuffix; - if (useExternalIds) { - const prefix = getTranslationConstPrefix(`EXTERNAL_`); - const uniqueSuffix = pool.uniqueName(suffix); - name = `${prefix}${sanitizeIdentifier(messageId)}$$${uniqueSuffix}`; - } else { - const prefix = getTranslationConstPrefix(suffix); - name = pool.uniqueName(prefix); - } - return o.variable(name); -} diff --git a/packages/compiler/src/template/pipeline/src/phases/propagate_i18n_placeholders.ts b/packages/compiler/src/template/pipeline/src/phases/propagate_i18n_placeholders.ts new file mode 100644 index 0000000000000..1b61321bed797 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/propagate_i18n_placeholders.ts @@ -0,0 +1,66 @@ +/** + * @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 * as ir from '../../ir'; +import {ComponentCompilationJob} from '../compilation'; + +/** + * Propagate extractd message placeholders up to their root extracted message op. + */ +export function phasePropagateI18nPlaceholders(job: ComponentCompilationJob) { + // Record all of the i18n and extracted message ops for use later. + const i18nOps = new Map(); + const extractedMessageOps = new Map(); + for (const unit of job.units) { + for (const op of unit.create) { + switch (op.kind) { + case ir.OpKind.I18nStart: + i18nOps.set(op.xref, op); + break; + case ir.OpKind.ExtractedMessage: + extractedMessageOps.set(op.owner, op); + break; + } + } + } + + // For each non-root message, merge its params into the root message's params. + for (const [xref, childExtractedMessageOp] of extractedMessageOps) { + if (!childExtractedMessageOp.isRoot) { + const i18nOp = i18nOps.get(xref); + if (i18nOp === undefined) { + throw Error('Could not find owner i18n block for extracted message.'); + } + const rootExtractedMessageOp = extractedMessageOps.get(i18nOp.root); + if (rootExtractedMessageOp === undefined) { + throw Error('Could not find extracted message op for root i18n block.'); + } + mergeParams(rootExtractedMessageOp.params, childExtractedMessageOp.params); + mergeParams( + rootExtractedMessageOp.postprocessingParams, + childExtractedMessageOp.postprocessingParams); + } + } +} + +/** + * Merges the params in the `from` map to into the `to` map. + */ +function mergeParams(to: Map, from: Map) { + for (const [placeholder, fromValues] of from) { + const toValues = to.get(placeholder) || []; + // TODO(mmalerba): Child element close tag params should be prepended to maintain the same order + // as TemplateDefinitionBuilder. Can be cleaned up when compatibility is no longer required. + const flags = fromValues[0]!.flags; + if ((flags & ir.I18nParamValueFlags.CloseTag) && !(flags & ir.I18nParamValueFlags.OpenTag)) { + to.set(placeholder, [...fromValues, ...toValues]); + } else { + to.set(placeholder, [...toValues, ...fromValues]); + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_element_placeholders.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_element_placeholders.ts new file mode 100644 index 0000000000000..758130d536ba6 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_element_placeholders.ts @@ -0,0 +1,128 @@ +/** + * @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 * as ir from '../../ir'; +import {ComponentCompilationJob} from '../compilation'; + +/** + * Resolve the element placeholders in i18n messages. + */ +export function phaseResolveI18nElementPlaceholders(job: ComponentCompilationJob) { + // Record all of the element and extracted message ops for use later. + const extractedMessageOps = new Map(); + const elements = new Map(); + for (const unit of job.units) { + for (const op of unit.create) { + switch (op.kind) { + case ir.OpKind.ExtractedMessage: + extractedMessageOps.set(op.owner, op); + break; + case ir.OpKind.ElementStart: + elements.set(op.xref, op); + break; + } + } + } + + for (const unit of job.units) { + // Track the current i18n op and corresponding extracted message op as we step through the + // creation IR. + let currentOps: {i18n: ir.I18nStartOp, extractedMessage: ir.ExtractedMessageOp}|null = null; + + for (const op of unit.create) { + switch (op.kind) { + case ir.OpKind.I18nStart: + if (!extractedMessageOps.has(op.xref)) { + throw Error('Could not find extracted message for i18n op'); + } + currentOps = {i18n: op, extractedMessage: extractedMessageOps.get(op.xref)!}; + break; + case ir.OpKind.I18nEnd: + currentOps = null; + break; + case ir.OpKind.ElementStart: + // For elements with i18n placeholders, record its slot value in the params map under the + // corresponding tag start placeholder. + if (op.i18nPlaceholder !== undefined) { + if (currentOps === null) { + throw Error('i18n tag placeholder should only occur inside an i18n block'); + } + const {startName, closeName} = op.i18nPlaceholder; + let flags = ir.I18nParamValueFlags.ElementTag | ir.I18nParamValueFlags.OpenTag; + // For self-closing tags, there is no close tag placeholder. Instead, the start tag + // placeholder accounts for the start and close of the element. + if (closeName === '') { + flags |= ir.I18nParamValueFlags.CloseTag; + } + addParam( + currentOps.extractedMessage.params, startName, op.slot!, + currentOps.i18n.subTemplateIndex, flags); + } + break; + case ir.OpKind.ElementEnd: + // For elements with i18n placeholders, record its slot value in the params map under the + // corresponding tag close placeholder. + const startOp = elements.get(op.xref); + if (startOp && startOp.i18nPlaceholder !== undefined) { + if (currentOps === null) { + throw Error('i18n tag placeholder should only occur inside an i18n block'); + } + const {closeName} = startOp.i18nPlaceholder; + // Self-closing tags don't have a closing tag placeholder. + if (closeName !== '') { + addParam( + currentOps.extractedMessage!.params, closeName, startOp.slot!, + currentOps.i18n.subTemplateIndex, + ir.I18nParamValueFlags.ElementTag | ir.I18nParamValueFlags.CloseTag); + } + } + break; + case ir.OpKind.Template: + // For templates with i18n placeholders, record its slot value in the params map under the + // corresponding template start and close placeholders. + if (op.i18nPlaceholder !== undefined) { + if (currentOps === null) { + throw Error('i18n tag placeholder should only occur inside an i18n block'); + } + const subTemplateIndex = getSubTemplateIndexForTemplateTag(job, currentOps.i18n, op); + addParam( + currentOps.extractedMessage.params, op.i18nPlaceholder.startName, op.slot!, + subTemplateIndex, ir.I18nParamValueFlags.TemplateTag); + addParam( + currentOps.extractedMessage.params, op.i18nPlaceholder.closeName, op.slot!, + subTemplateIndex, + ir.I18nParamValueFlags.TemplateTag | ir.I18nParamValueFlags.CloseTag); + } + break; + } + } + } +} + +/** + * Get the subTemplateIndex for the given template op. For template ops, use the subTemplateIndex of + * the child i18n block inside the template. + */ +function getSubTemplateIndexForTemplateTag( + job: ComponentCompilationJob, i18nOp: ir.I18nStartOp, op: ir.TemplateOp): number|null { + for (const childOp of job.views.get(op.xref)!.create) { + if (childOp.kind === ir.OpKind.I18nStart) { + return childOp.subTemplateIndex; + } + } + return i18nOp.subTemplateIndex; +} + +/** Add a param value to the given params map. */ +function addParam( + params: Map, placeholder: string, value: string|number, + subTemplateIndex: number|null, flags = ir.I18nParamValueFlags.None) { + const values = params.get(placeholder) ?? []; + values.push({value, subTemplateIndex, flags}); + params.set(placeholder, values); +} diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_expression_placeholders.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_expression_placeholders.ts new file mode 100644 index 0000000000000..9dd739a8a94e7 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_expression_placeholders.ts @@ -0,0 +1,64 @@ +/** + * @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 * as ir from '../../ir'; +import {ComponentCompilationJob} from '../compilation'; + +/** + * Resolve the i18n expression placeholders in i18n messages. + */ +export function phaseResolveI18nExpressionPlaceholders(job: ComponentCompilationJob) { + // Record all of the i18n and extracted message ops for use later. + const i18nOps = new Map(); + const extractedMessageOps = new Map(); + for (const unit of job.units) { + for (const op of unit.create) { + switch (op.kind) { + case ir.OpKind.I18nStart: + i18nOps.set(op.xref, op); + break; + case ir.OpKind.ExtractedMessage: + extractedMessageOps.set(op.owner, op); + break; + } + } + } + + // Keep track of the next available expression index per i18n block. + const expressionIndices = new Map(); + + for (const unit of job.units) { + for (const op of unit.update) { + if (op.kind === ir.OpKind.I18nExpression) { + const i18nOp = i18nOps.get(op.owner); + let index = expressionIndices.get(op.owner) || 0; + if (!i18nOp) { + throw Error('Cannot find corresponding i18n block for i18nExpr'); + } + const extractedMessageOp = extractedMessageOps.get(i18nOp.xref); + if (!extractedMessageOp) { + throw Error('Cannot find extracted message for i18n block'); + } + + // Add the expression index in the appropriate params map. + const params = op.resolutionTime === ir.I18nParamResolutionTime.Creation ? + extractedMessageOp.params : + extractedMessageOp.postprocessingParams; + const values = params.get(op.i18nPlaceholder) || []; + values.push({ + value: index, + subTemplateIndex: i18nOp.subTemplateIndex, + flags: ir.I18nParamValueFlags.None + }); + params.set(op.i18nPlaceholder, values); + + expressionIndices.set(op.owner, index + 1); + } + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_placeholders.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_placeholders.ts deleted file mode 100644 index d10c760c283ac..0000000000000 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_i18n_placeholders.ts +++ /dev/null @@ -1,366 +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 * as o from '../../../../output/output_ast'; -import * as ir from '../../ir'; -import {ComponentCompilationJob} from '../compilation'; - -/** - * The escape sequence used indicate message param values. - */ -const ESCAPE = '\uFFFD'; - -/** - * Marker used to indicate an element tag. - */ -const ELEMENT_MARKER = '#'; - -/** - * Marker used to indicate a template tag. - */ -const TEMPLATE_MARKER = '*'; - -/** - * Marker used to indicate closing of an element or template tag. - */ -const TAG_CLOSE_MARKER = '/'; - -/** - * Marker used to indicate the sub-template context. - */ -const CONTEXT_MARKER = ':'; - -/** - * Marker used to indicate the start of a list of values. - */ -const LIST_START_MARKER = '['; - -/** - * Marker used to indicate the end of a list of values. - */ -const LIST_END_MARKER = ']'; - -/** - * Delimiter used to separate multiple values in a list. - */ -const LIST_DELIMITER = '|'; - -/** - * Flags that describe what an i18n param value. These determine how the value is serialized into - * the final map. - */ -enum I18nParamValueFlags { - None = 0b0000, - - /** - * This value represtents an element tag. - */ - ElementTag = 0b001, - - /** - * This value represents a template tag. - */ - TemplateTag = 0b0010, - - /** - * This value represents the opening of a tag. - */ - OpenTag = 0b0100, - - /** - * This value represents the closing of a tag. - */ - CloseTag = 0b1000, -} - -/** - * Represents a single placeholder value in the i18n params map. The map may contain multiple - * I18nPlaceholderValue per placeholder. - */ -interface I18nPlaceholderValue { - /** - * The value. - */ - value: string|number; - - /** - * The sub-template index associated with the value. - */ - subTemplateIndex: number|null; - - /** - * Flags associated with the value. - */ - flags: I18nParamValueFlags; - - /** - * The time when the placeholder value is resolved. - */ - resolutionTime: ir.I18nParamResolutionTime; -} - -/** - * Represents the complete i18n params map for an i18n op. - */ -class I18nPlaceholderParams { - values = new Map(); - - /** - * Adds a new value to the params map. - */ - addValue( - placeholder: string, value: string|number, subTemplateIndex: number|null, - resolutionTime: ir.I18nParamResolutionTime, flags: I18nParamValueFlags) { - const placeholderValues = this.values.get(placeholder) ?? []; - placeholderValues.push({value, subTemplateIndex, resolutionTime, flags}); - this.values.set(placeholder, placeholderValues); - } - - /** - * Saves the params map, in serialized form, into the given i18n op. - */ - saveToOp(op: ir.I18nStartOp) { - for (const [placeholder, placeholderValues] of this.values) { - // We need to run post-processing for any 1i8n ops that contain parameters with more than - // one value, even if there are no parameters resolved at post-processing time. - const creationValues = placeholderValues.filter( - ({resolutionTime}) => resolutionTime === ir.I18nParamResolutionTime.Creation); - if (creationValues.length > 1) { - op.needsPostprocessing = true; - } - - // Save creation time params to op. - const serializedCreationValues = this.serializeValues(creationValues); - if (serializedCreationValues !== null) { - op.params.set(placeholder, o.literal(serializedCreationValues)); - } - - // Save post-processing time params to op. - const serializedPostprocessingValues = this.serializeValues(placeholderValues.filter( - ({resolutionTime}) => resolutionTime === ir.I18nParamResolutionTime.Postproccessing)); - if (serializedPostprocessingValues !== null) { - op.needsPostprocessing = true; - op.postprocessingParams.set(placeholder, o.literal(serializedPostprocessingValues)); - } - } - } - - /** - * Merges another param map into this one. - */ - merge(other: I18nPlaceholderParams) { - for (const [placeholder, otherValues] of other.values) { - const currentValues = this.values.get(placeholder) || []; - // Child element close tag params should be prepended to maintain the same order as - // TemplateDefinitionBuilder. - const flags = otherValues[0]!.flags; - if ((flags & I18nParamValueFlags.CloseTag) && !(flags & I18nParamValueFlags.OpenTag)) { - this.values.set(placeholder, [...otherValues, ...currentValues]); - } else { - this.values.set(placeholder, [...currentValues, ...otherValues]); - } - } - } - - /** - * Serializes a list of i18n placeholder values. - */ - private serializeValues(values: I18nPlaceholderValue[]) { - if (values.length === 0) { - return null; - } - const serializedValues = values.map(value => this.serializeValue(value)); - return serializedValues.length === 1 ? - serializedValues[0] : - `${LIST_START_MARKER}${serializedValues.join(LIST_DELIMITER)}${LIST_END_MARKER}`; - } - - /** - * Serializes a single i18n placeholder value. - */ - private serializeValue(value: I18nPlaceholderValue) { - let tagMarker = ''; - let closeMarker = ''; - if (value.flags & I18nParamValueFlags.ElementTag) { - tagMarker = ELEMENT_MARKER; - } else if (value.flags & I18nParamValueFlags.TemplateTag) { - tagMarker = TEMPLATE_MARKER; - } - if (tagMarker !== '') { - closeMarker = value.flags & I18nParamValueFlags.CloseTag ? TAG_CLOSE_MARKER : ''; - } - const context = - value.subTemplateIndex === null ? '' : `${CONTEXT_MARKER}${value.subTemplateIndex}`; - // Self-closing tags use a special form that concatenates the start and close tag values. - if ((value.flags & I18nParamValueFlags.OpenTag) && - (value.flags & I18nParamValueFlags.CloseTag)) { - return `${ESCAPE}${tagMarker}${value.value}${context}${ESCAPE}${ESCAPE}${closeMarker}${ - tagMarker}${value.value}${context}${ESCAPE}`; - } - return `${ESCAPE}${closeMarker}${tagMarker}${value.value}${context}${ESCAPE}`; - } -} - -/** - * Resolve the placeholders in i18n messages. - */ -export function phaseResolveI18nPlaceholders(job: ComponentCompilationJob) { - const params = new Map(); - const i18nOps = new Map(); - - resolvePlaceholders(job, params, i18nOps); - propagatePlaceholders(params, i18nOps); - - // After colleccting all params, save them to the i18n ops. - for (const [xref, i18nOpParams] of params) { - i18nOpParams.saveToOp(i18nOps.get(xref)!); - } - - // Validate the root i18n ops have all placeholders filled in. - for (const op of i18nOps.values()) { - if (op.xref === op.root) { - for (const placeholder in op.message.placeholders) { - if (!op.params.has(placeholder) && !op.postprocessingParams.has(placeholder)) { - throw Error(`Failed to resolve i18n placeholder: ${placeholder}`); - } - } - } - } -} - -/** - * Resolve placeholders for each i18n op. - */ -function resolvePlaceholders( - job: ComponentCompilationJob, params: Map, - i18nOps: Map) { - for (const unit of job.units) { - const elements = new Map(); - let currentI18nOp: ir.I18nStartOp|null = null; - - // Record slots for tag name placeholders. - for (const op of unit.create) { - switch (op.kind) { - case ir.OpKind.I18nStart: - i18nOps.set(op.xref, op); - currentI18nOp = op.kind === ir.OpKind.I18nStart ? op : null; - break; - case ir.OpKind.I18nEnd: - currentI18nOp = null; - break; - case ir.OpKind.ElementStart: - // For elements with i18n placeholders, record its slot value in the params map under the - // corresponding tag start placeholder. - if (op.i18nPlaceholder !== undefined) { - if (currentI18nOp === null) { - throw Error('i18n tag placeholder should only occur inside an i18n block'); - } - elements.set(op.xref, op); - const {startName, closeName} = op.i18nPlaceholder; - let flags = I18nParamValueFlags.ElementTag | I18nParamValueFlags.OpenTag; - // For self-closing tags, there is no close tag placeholder. Instead, the start tag - // placeholder accounts for the start and close of the element. - if (closeName === '') { - flags |= I18nParamValueFlags.CloseTag; - } - addParam( - params, currentI18nOp, startName, op.slot!, currentI18nOp.subTemplateIndex, - ir.I18nParamResolutionTime.Creation, flags); - } - break; - case ir.OpKind.ElementEnd: - const startOp = elements.get(op.xref); - if (startOp && startOp.i18nPlaceholder !== undefined) { - if (currentI18nOp === null) { - throw Error('i18n tag placeholder should only occur inside an i18n block'); - } - const {closeName} = startOp.i18nPlaceholder; - // Self-closing tags don't have a closing tag placeholder. - if (closeName !== '') { - addParam( - params, currentI18nOp, closeName, startOp.slot!, currentI18nOp.subTemplateIndex, - ir.I18nParamResolutionTime.Creation, - I18nParamValueFlags.ElementTag | I18nParamValueFlags.CloseTag); - } - } - break; - case ir.OpKind.Template: - if (op.i18nPlaceholder !== undefined) { - if (currentI18nOp === null) { - throw Error('i18n tag placeholder should only occur inside an i18n block'); - } - const subTemplateIndex = getSubTemplateIndexForTemplateTag(job, currentI18nOp, op); - addParam( - params, currentI18nOp, op.i18nPlaceholder.startName, op.slot!, subTemplateIndex, - ir.I18nParamResolutionTime.Creation, I18nParamValueFlags.TemplateTag); - addParam( - params, currentI18nOp, op.i18nPlaceholder.closeName, op.slot!, subTemplateIndex, - ir.I18nParamResolutionTime.Creation, - I18nParamValueFlags.TemplateTag | I18nParamValueFlags.CloseTag); - } - break; - } - } - - // Fill in values for each of the i18n expression placeholders. - const i18nBlockPlaceholderIndices = new Map(); - for (const op of unit.update) { - if (op.kind === ir.OpKind.I18nExpression) { - const i18nOp = i18nOps.get(op.owner); - let index = i18nBlockPlaceholderIndices.get(op.owner) || 0; - if (!i18nOp) { - throw Error('Cannot find corresponding i18nStart for i18nExpr'); - } - addParam( - params, i18nOp, op.i18nPlaceholder, index++, i18nOp.subTemplateIndex, - op.resolutionTime); - i18nBlockPlaceholderIndices.set(op.owner, index); - } - } - } -} - -/** - * Add a param to the params map for the given i18n op. - */ -function addParam( - params: Map, i18nOp: ir.I18nStartOp, placeholder: string, - value: string|number, subTemplateIndex: number|null, resolutionTime: ir.I18nParamResolutionTime, - flags: I18nParamValueFlags = I18nParamValueFlags.None) { - const i18nOpParams = params.get(i18nOp.xref) || new I18nPlaceholderParams(); - i18nOpParams.addValue(placeholder, value, subTemplateIndex, resolutionTime, flags); - params.set(i18nOp.xref, i18nOpParams); -} - -/** - * Get the subTemplateIndex for the given template op. For template ops, use the subTemplateIndex of - * the child i18n block inside the template. - */ -function getSubTemplateIndexForTemplateTag( - job: ComponentCompilationJob, i18nOp: ir.I18nStartOp, op: ir.TemplateOp): number|null { - for (const childOp of job.views.get(op.xref)!.create) { - if (childOp.kind === ir.OpKind.I18nStart) { - return childOp.subTemplateIndex; - } - } - return i18nOp.subTemplateIndex; -} - -/** - * Propagate placeholders up to their root i18n op. - */ -function propagatePlaceholders( - params: Map, i18nOps: Map) { - for (const [xref, opParams] of params) { - const op = i18nOps.get(xref)!; - if (op.xref !== op.root) { - const rootParams = params.get(op.root) || new I18nPlaceholderParams(); - rootParams.merge(opParams); - } - } -} From 6070c9ddcff88d4ad4bcf73a2dd1874920661d93 Mon Sep 17 00:00:00 2001 From: cexbrayat Date: Fri, 27 Oct 2023 18:33:39 +0200 Subject: [PATCH 15/52] fix(core): handle trackBy and aliased index in control flow migration (#52423) Currently, the migration always use `$index` in the migrated trackBy function, whereas this variable might be aliased. The compiler then errors with: ``` error TS2339: Property '$index' does not exist on type 'UsersComponent'. 110 @for (user of users; track byId($index, user); let i = $index) { ``` This commit updates the migration to use the aliased index if there is one. PR Close #52423 --- .../control-flow-migration/util.ts | 15 +++++++++++ .../test/control_flow_migration_spec.ts | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/util.ts b/packages/core/schematics/ng-generate/control-flow-migration/util.ts index ff13d77dc49f9..fd0d362d865de 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/util.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/util.ts @@ -328,6 +328,7 @@ function migrateNgFor(etm: ElementToMigrate, tmpl: string, offset: number): Resu const condition = parts[0].replace('let ', ''); const loopVar = condition.split(' of ')[0]; let trackBy = loopVar; + let aliasedIndex: string|null = null; for (let i = 1; i < parts.length; i++) { const part = parts[i].trim(); @@ -343,6 +344,11 @@ function migrateNgFor(etm: ElementToMigrate, tmpl: string, offset: number): Resu const aliasParts = part.split('='); // -> 'let myIndex = $index' aliases.push(` ${aliasParts[0].trim()} = $${aliasParts[1].trim()}`); + // if the aliased variable is the index, then we store it + if (aliasParts[1].trim() === 'index') { + // 'let myIndex' -> 'myIndex' + aliasedIndex = aliasParts[0].trim().split(/\s+as\s+/)[1]; + } } // declared with `index as myIndex` if (part.match(aliasWithAsRegexp)) { @@ -350,8 +356,17 @@ function migrateNgFor(etm: ElementToMigrate, tmpl: string, offset: number): Resu const aliasParts = part.split(/\s+as\s+/); // -> 'let myIndex = $index' aliases.push(` let ${aliasParts[1].trim()} = $${aliasParts[0].trim()}`); + // if the aliased variable is the index, then we store it + if (aliasParts[0].trim() === 'index') { + aliasedIndex = aliasParts[1].trim(); + } } } + // if an alias has been defined for the index, then the trackBy function must use it + if (aliasedIndex !== null && trackBy !== loopVar) { + // byId($index, user) -> byId(i, user) + trackBy = trackBy.replace('$index', aliasedIndex); + } const aliasStr = (aliases.length > 0) ? `;${aliases.join(';')}` : ''; diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index f5b84e8cc4eda..93886eb2bab76 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -920,6 +920,31 @@ describe('control flow migration', () => { 'template: `
      @for (itm of items; track itm; let myIndex = $index) {
    • {{itm.text}}
    • }
    `'); }); + it('should migrate with a trackBy function and an aliased index', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
    • {{itm.text}}
    \` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
      @for (itm of items; track trackMeFn(i, itm); let i = $index) {
    • {{itm.text}}
    • }
    `'); + }); + it('should migrate with multiple aliases', async () => { writeFile('/comp.ts', ` import {Component} from '@angular/core'; From 868a548996384294b34c6638e271e515dd0899a4 Mon Sep 17 00:00:00 2001 From: Emma Twersky Date: Fri, 27 Oct 2023 18:16:40 -0700 Subject: [PATCH 16/52] docs: make banner persist until 11/8 (#52432) PR Close #52432 --- aio/src/app/app.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 8fb6fd2372ac1..6b98eb14735e6 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -11,7 +11,7 @@
    - + We have a lot to share, tune in on November 6th! From 8d52fa73ff0df3ad1a286205095a3c07fd04fa31 Mon Sep 17 00:00:00 2001 From: marktechson <2554588+MarkTechson@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:36:56 -0500 Subject: [PATCH 17/52] docs: update home page for live stream event (#52456) PR Close #52456 --- aio/content/marketing/index.html | 86 ++++++++++++------- .../marketing-layout/_marketing-layout.scss | 1 - 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/aio/content/marketing/index.html b/aio/content/marketing/index.html index e7f5d8a1f4dd1..cb3ab75aba1a3 100755 --- a/aio/content/marketing/index.html +++ b/aio/content/marketing/index.html @@ -1,45 +1,42 @@
    -
    - - - - -
    -
    -
    -
    -

    The web development framework for building the future

    +

    + The web development framework for building the future +

    @@ -48,35 +45,58 @@

    The web development framework for build

    Works at any scale

    -

    Hero image

    -

    Angular lets you start small and supports you as your team and apps grow. -

    +

    + Hero image +

    +

    + Angular lets you start small and supports you as your team and apps grow. +

    Read how Angular helps you grow

    -
    +

    Loved by millions

    -

    Hero image

    -

    Join the millions of developers building with Angular in a thriving and friendly community. +

    + Hero image +

    +

    + Join the millions of developers building with Angular in a thriving and friendly + community. +

    +

    + Meet the Angular community

    -

    Meet the Angular community

    Build for everyone

    -

    Hero image

    -

    Rely on Angular's internationalization tools, security, and accessibility to build for everyone around the world.

    +

    + Hero image +

    +

    + Rely on Angular's internationalization tools, security, and accessibility to build for + everyone around the world. +

    Learn more about Angular's tools

    -

    Learn more

    - +

    + Learn more +

    - + diff --git a/aio/src/styles/1-layouts/marketing-layout/_marketing-layout.scss b/aio/src/styles/1-layouts/marketing-layout/_marketing-layout.scss index 5d7c0ff50a89c..70303ba2f450a 100644 --- a/aio/src/styles/1-layouts/marketing-layout/_marketing-layout.scss +++ b/aio/src/styles/1-layouts/marketing-layout/_marketing-layout.scss @@ -43,7 +43,6 @@ section#intro { position: relative; max-width: 900px; width: 100%; - height: 480px; margin: 0 auto; padding: 48px 0 0; From ac2d0c619e9a740601d05ed53296b9fcf4e35bbb Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 20 Oct 2023 11:33:37 -0700 Subject: [PATCH 18/52] perf(core): Update LView consumer to only mark component for check (#52302) This commit updates the reactive template and host binding consumers to only mark their declaration components for refresh, but not parents/ancestors. This also updates the `AfterViewChecked` hook to run when a component is refreshed during change detection but its host is not. It is reasonable to expect that the `ngAfterViewChecked` lifecycle hook will run when a signal updates and the component is refreshed. The hooks are typically run when the host is refreshed so without this change, the update to not mark ancestors dirty would have caused `ngAfterViewChecked` to not run. resolves #14628 resolves #22646 resolves #34347 - this is not the direct request of the issue but generally forcing change detection to run is necessary only because a value was updated that needs to be synced to the DOM. Values that use signals will mark the component for check automatically so accessing the `ChangeDetectorRef` of a child is not necessary. The other part of this request was to avoid the need to "mark all views for checking since it wouldn't affect anything but itself". This is directly addressed by this commit - updating a signal that's read in the view's template will not cause ancestors/"all views" to be refreshed. PR Close #52302 --- .../render3/instructions/change_detection.ts | 13 +- .../src/render3/reactive_lview_consumer.ts | 7 +- packages/core/src/render3/util/view_utils.ts | 21 +- .../change_detection_signals_in_zones_spec.ts | 318 +++++++++++++++- ...change_detection_transplanted_view_spec.ts | 360 +++++++++++------- 5 files changed, 573 insertions(+), 146 deletions(-) diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 5e292d8c54770..f5f2ba5ca7728 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -13,7 +13,7 @@ import {getComponentViewByInstance} from '../context_discovery'; import {executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags} from '../hooks'; import {CONTAINER_HEADER_OFFSET, HAS_CHILD_VIEWS_TO_REFRESH, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS} from '../interfaces/container'; import {ComponentTemplate, RenderFlags} from '../interfaces/definition'; -import {CONTEXT, ENVIRONMENT, FLAGS, InitPhaseState, LView, LViewFlags, PARENT, REACTIVE_TEMPLATE_CONSUMER, TVIEW, TView} from '../interfaces/view'; +import {CONTEXT, ENVIRONMENT, FLAGS, InitPhaseState, LView, LViewFlags, PARENT, REACTIVE_TEMPLATE_CONSUMER, TVIEW, TView, TViewType} from '../interfaces/view'; import {enterView, isInCheckNoChangesMode, leaveView, setBindingIndex, setIsInCheckNoChangesMode} from '../state'; import {getFirstLContainer, getNextLContainer} from '../util/view_traversal_utils'; import {getComponentLViewByIndex, isCreationMode, markAncestorsForTraversal, markViewForRefresh, resetPreOrderHookFlags, viewAttachedToChangeDetector} from '../util/view_utils'; @@ -344,6 +344,7 @@ function detectChangesInViewIfAttached(lView: LView, mode: ChangeDetectionMode) * view HasChildViewsToRefresh flag is set. */ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) { + const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode(); const tView = lView[TVIEW]; const flags = lView[FLAGS]; @@ -351,8 +352,14 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) { // necessary. lView[FLAGS] &= ~(LViewFlags.HasChildViewsToRefresh | LViewFlags.RefreshView); - if ((flags & (LViewFlags.CheckAlways | LViewFlags.Dirty) && - mode === ChangeDetectionMode.Global) || + if ((flags & LViewFlags.CheckAlways && mode === ChangeDetectionMode.Global) || + (flags & LViewFlags.Dirty && mode === ChangeDetectionMode.Global && + // CheckNoChanges never worked with `OnPush` components because the `Dirty` flag was cleared + // before checkNoChanges ran. Because there is now a loop for to check for backwards views, + // it gives an opportunity for `OnPush` components to be marked `Dirty` before the + // CheckNoChanges pass. We don't want existing errors that are hidden by the current + // CheckNoChanges bug to surface when making unrelated changes. + !isInCheckNoChangesPass) || flags & LViewFlags.RefreshView) { refreshView(tView, lView, tView.template, lView[CONTEXT]); } else if (flags & LViewFlags.HasChildViewsToRefresh) { diff --git a/packages/core/src/render3/reactive_lview_consumer.ts b/packages/core/src/render3/reactive_lview_consumer.ts index ed7cdedc15aa2..2bb276a3de632 100644 --- a/packages/core/src/render3/reactive_lview_consumer.ts +++ b/packages/core/src/render3/reactive_lview_consumer.ts @@ -8,11 +8,8 @@ import {REACTIVE_NODE, ReactiveNode} from '@angular/core/primitives/signals'; -import {RuntimeError} from '../errors'; -import {assertDefined} from '../util/assert'; - -import {markViewDirty} from './instructions/mark_view_dirty'; import {LView, REACTIVE_HOST_BINDING_CONSUMER, REACTIVE_TEMPLATE_CONSUMER} from './interfaces/view'; +import {markViewDirtyFromSignal} from './util/view_utils'; let currentConsumer: ReactiveLViewConsumer|null = null; export interface ReactiveLViewConsumer extends ReactiveNode { @@ -43,7 +40,7 @@ const REACTIVE_LVIEW_CONSUMER_NODE: Omit ` in ExpressionChangedAfterItHasBeenChecked errors or future updates not working` + ` entirely.`); } - markViewDirty(node.lView); + markViewDirtyFromSignal(node.lView); }, consumerOnSignalRead(this: ReactiveLViewConsumer): void { if (currentConsumer !== this) { diff --git a/packages/core/src/render3/util/view_utils.ts b/packages/core/src/render3/util/view_utils.ts index 8e792824f4b16..da88a183d1153 100644 --- a/packages/core/src/render3/util/view_utils.ts +++ b/packages/core/src/render3/util/view_utils.ts @@ -13,7 +13,7 @@ import {HAS_CHILD_VIEWS_TO_REFRESH, LContainer, TYPE} from '../interfaces/contai import {TConstants, TNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {isLContainer, isLView} from '../interfaces/type_checks'; -import {DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, TData, TView} from '../interfaces/view'; +import {DECLARATION_COMPONENT_VIEW, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, TData, TView} from '../interfaces/view'; @@ -241,6 +241,25 @@ export function markAncestorsForTraversal(lView: LView) { } } +/** + * Marks the component or root view of an LView for refresh. + * + * This function locates the declaration component view of a given LView and marks it for refresh. + * With this, we get component-level change detection granularity. Marking the `LView` itself for + * refresh would be view-level granularity. + * + * Note that when an LView is a root view, the DECLARATION_COMPONENT_VIEW will be the root view + * itself. This is a bit confusing since the TView.type is `Root`, rather than `Component`, but this + * is actually what we need for host bindings in a root view. + */ +export function markViewDirtyFromSignal(lView: LView): void { + const declarationComponentView = lView[DECLARATION_COMPONENT_VIEW]; + declarationComponentView[FLAGS] |= LViewFlags.RefreshView; + if (viewAttachedToChangeDetector(declarationComponentView)) { + markAncestorsForTraversal(declarationComponentView); + } +} + /** * Stores a LView-specific destroy callback. */ diff --git a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts index 7595ee41107ff..6fe555c4e8e1c 100644 --- a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts +++ b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {NgIf} from '@angular/common'; -import {ChangeDetectionStrategy, Component, Directive, Input, signal, ViewChild} from '@angular/core'; +import {NgFor, NgIf} from '@angular/common'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, Input, signal, untracked, ViewChild} from '@angular/core'; import {TestBed} from '@angular/core/testing'; describe('CheckAlways components', () => { @@ -88,6 +88,87 @@ describe('CheckAlways components', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent.trim()).toBe('new'); }); + + it('continues to refresh views until none are dirty', () => { + const aVal = signal('initial'); + const bVal = signal('initial'); + let updateAValDuringAChangeDetection = false; + + @Component({ + template: '{{val()}}', + standalone: true, + selector: 'a-comp', + }) + class A { + val = aVal; + } + @Component({ + template: '{{val()}}', + standalone: true, + selector: 'b-comp', + }) + class B { + val = bVal; + ngAfterViewChecked() { + // Set value in parent view after this view is checked + // Without signals, this is ExpressionChangedAfterItWasChecked + if (updateAValDuringAChangeDetection) { + aVal.set('new'); + } + } + } + + @Component({template: '-', standalone: true, imports: [A, B]}) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.innerText).toContain('initial-initial'); + + bVal.set('new'); + fixture.detectChanges(); + expect(fixture.nativeElement.innerText).toContain('initial-new'); + + updateAValDuringAChangeDetection = true; + bVal.set('newer'); + fixture.detectChanges(); + expect(fixture.nativeElement.innerText).toContain('new-newer'); + }); + + it('refreshes root view until it is no longer dirty', () => { + const val = signal(0); + let incrementAfterCheckedUntil = 0; + @Component({ + template: '', + selector: 'child', + standalone: true, + }) + class Child { + ngDoCheck() { + // Update signal in parent view every time we check the child view + // (ExpressionChangedAfterItWasCheckedError but not for signals) + if (val() < incrementAfterCheckedUntil) { + val.update(v => ++v); + } + } + } + @Component({template: '{{val()}}', standalone: true, imports: [Child]}) + class App { + val = val; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.innerText).toContain('0'); + + incrementAfterCheckedUntil = 10; + fixture.detectChanges(); + expect(fixture.nativeElement.innerText).toContain('10'); + + incrementAfterCheckedUntil = Number.MAX_SAFE_INTEGER; + expect(() => fixture.detectChanges()).toThrowError(/Infinite/); + }); }); @@ -272,6 +353,37 @@ describe('OnPush components with signals', () => { expect(instance.numTemplateExecutions).toBe(1); }); + it('can read a signal in a host binding in root view', () => { + const useBlue = signal(false); + @Component({ + template: `{{incrementTemplateExecutions()}}`, + selector: 'child', + host: {'[class.blue]': 'useBlue()'}, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + }) + class MyCmp { + useBlue = useBlue; + + numTemplateExecutions = 0; + incrementTemplateExecutions() { + this.numTemplateExecutions++; + return ''; + } + } + + const fixture = TestBed.createComponent(MyCmp); + + fixture.detectChanges(); + expect(fixture.nativeElement.outerHTML).not.toContain('blue'); + expect(fixture.componentInstance.numTemplateExecutions).not.toContain(1); + + useBlue.set(true); + fixture.detectChanges(); + expect(fixture.nativeElement.outerHTML).toContain('blue'); + expect(fixture.componentInstance.numTemplateExecutions).not.toContain(1); + }); + it('can read a signal in a host binding', () => { @Component({ template: `{{incrementTemplateExecutions()}}`, @@ -440,4 +552,206 @@ describe('OnPush components with signals', () => { .toHaveBeenCalledWith(jasmine.stringMatching( /will likely result in ExpressionChangedAfterItHasBeenChecked/)); }); + + it('does not refresh view if signal marked dirty but did not change', () => { + const val = signal('initial', {equal: () => true}); + + @Component({ + template: '{{val()}}{{incrementChecks()}}', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class App { + val = val; + templateExecutions = 0; + incrementChecks() { + this.templateExecutions++; + } + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.componentInstance.templateExecutions).toBe(1); + expect(fixture.nativeElement.innerText).toContain('initial'); + + val.set('new'); + fixture.detectChanges(); + expect(fixture.componentInstance.templateExecutions).toBe(1); + expect(fixture.nativeElement.innerText).toContain('initial'); + }); + + describe('embedded views', () => { + it('refreshes an embedded view in a component', () => { + @Component({ + selector: 'signal-component', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgIf], + template: `
    {{value()}}
    `, + }) + class SignalComponent { + value = signal('initial'); + } + + const fixture = TestBed.createComponent(SignalComponent); + fixture.detectChanges(); + fixture.componentInstance.value.set('new'); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('new'); + }); + + it('refreshes multiple embedded views in a component', () => { + @Component({ + selector: 'signal-component', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgFor], + template: `
    {{value()}}
    `, + }) + class SignalComponent { + value = signal('initial'); + } + + const fixture = TestBed.createComponent(SignalComponent); + fixture.detectChanges(); + fixture.componentInstance.value.set('new'); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('new new new'); + }); + + + it('refreshes entire component, including embedded views, when signal updates', () => { + @Component({ + selector: 'signal-component', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgIf], + template: ` + {{componentSignal()}} +
    {{incrementExecutions()}}
    + `, + }) + class SignalComponent { + embeddedViewExecutions = 0; + componentSignal = signal('initial'); + incrementExecutions() { + this.embeddedViewExecutions++; + return ''; + } + } + + const fixture = TestBed.createComponent(SignalComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.embeddedViewExecutions).toEqual(1); + + fixture.componentInstance.componentSignal.set('new'); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('new'); + // OnPush/Default components are checked as a whole so the embedded view is also checked again + expect(fixture.componentInstance.embeddedViewExecutions).toEqual(2); + }); + + + it('re-executes deep embedded template if signal updates', () => { + @Component({ + selector: 'signal-component', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIf], + template: ` +
    +
    +
    + {{value()}} +
    +
    +
    + `, + }) + class SignalComponent { + value = signal('initial'); + } + + const fixture = TestBed.createComponent(SignalComponent); + fixture.detectChanges(); + + fixture.componentInstance.value.set('new'); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('new'); + }); + }); + + describe('shielded by non-dirty OnPush', () => { + @Component({ + selector: 'signal-component', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + template: `{{value()}}`, + }) + class SignalComponent { + value = signal('initial'); + afterViewCheckedRuns = 0; + constructor(readonly cdr: ChangeDetectorRef) {} + ngAfterViewChecked() { + this.afterViewCheckedRuns++; + } + } + + @Component({ + selector: 'on-push-parent', + template: `{{incrementChecks()}}`, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [SignalComponent], + }) + class OnPushParent { + @ViewChild(SignalComponent) signalChild!: SignalComponent; + viewExecutions = 0; + + constructor(readonly cdr: ChangeDetectorRef) {} + incrementChecks() { + this.viewExecutions++; + } + } + + it('refreshes when signal changes, but does not refresh non-dirty parent', () => { + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + expect(fixture.componentInstance.viewExecutions).toEqual(1); + fixture.componentInstance.signalChild.value.set('new'); + fixture.detectChanges(); + expect(fixture.componentInstance.viewExecutions).toEqual(1); + expect(trim(fixture.nativeElement.textContent)).toEqual('new'); + }); + + it('does not refresh when detached', () => { + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + fixture.componentInstance.signalChild.value.set('new'); + fixture.componentInstance.signalChild.cdr.detach(); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('initial'); + }); + + // Note: Design decision for signals because that's how the hooks work today + // We have considered actually running a component's `afterViewChecked` hook if it's refreshed + // in targeted mode (meaning the parent did not refresh) and could change this decision. + it('does not run afterViewChecked hooks because parent view was not dirty (those hooks are executed by the parent)', + () => { + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + // hook run once on initialization + expect(fixture.componentInstance.signalChild.afterViewCheckedRuns).toBe(1); + fixture.componentInstance.signalChild.value.set('new'); + fixture.detectChanges(); + expect(trim(fixture.nativeElement.textContent)).toEqual('new'); + // hook did not run again because host view was not refreshed + expect(fixture.componentInstance.signalChild.afterViewCheckedRuns).toBe(1); + }); + }); }); + + +function trim(text: string|null): string { + return text ? text.replace(/[\s\n]+/gm, ' ').trim() : ''; +} diff --git a/packages/core/test/acceptance/change_detection_transplanted_view_spec.ts b/packages/core/test/acceptance/change_detection_transplanted_view_spec.ts index 600b92b2f1871..51c8c0db8d94c 100644 --- a/packages/core/test/acceptance/change_detection_transplanted_view_spec.ts +++ b/packages/core/test/acceptance/change_detection_transplanted_view_spec.ts @@ -7,14 +7,18 @@ */ import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, inject, Input, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, DoCheck, inject, Input, signal, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; import {AfterViewChecked, EmbeddedViewRef} from '@angular/core/src/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; describe('change detection for transplanted views', () => { describe('when declaration appears before insertion', () => { - const insertCompTemplate = ` - InsertComp({{greeting}}) + @Component({ + selector: 'onpush-insert-comp', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + OnPushInsertComp({{greeting}})
    { [ngTemplateOutletContext]="{$implicit: greeting}">
    - `; - @Component({ - selector: 'insert-comp', - changeDetection: ChangeDetectionStrategy.OnPush, - template: insertCompTemplate, + `, }) - class InsertComp implements DoCheck, AfterViewChecked { + abstract class OnPushInsertComp implements DoCheck, AfterViewChecked { get template(): TemplateRef { - return declareComp.myTmpl; + return templateRef; } greeting: string = 'Hello'; constructor(public changeDetectorRef: ChangeDetectorRef) { - if (!(this instanceof InsertForOnPushDeclareComp)) { - insertComp = this; - } + onPushInsertComp = this; } ngDoCheck(): void { logValue = 'Insert'; @@ -46,50 +44,42 @@ describe('change detection for transplanted views', () => { } } - @Component({ - selector: 'insert-for-onpush-declare-comp', - changeDetection: ChangeDetectionStrategy.OnPush, - template: insertCompTemplate, - }) - class InsertForOnPushDeclareComp extends InsertComp { - constructor(changeDetectorRef: ChangeDetectorRef) { - super(changeDetectorRef); - insertForOnPushDeclareComp = this; - } - override get template(): TemplateRef { - return onPushDeclareComp.myTmpl; - } - } - - @Component({ - selector: `declare-comp`, - template: ` - DeclareComp({{name}}) - - {{greeting}} {{logName()}}! - - ` - }) - class DeclareComp implements DoCheck, AfterViewChecked { + @Directive({}) + abstract class DeclareComp implements DoCheck, AfterViewChecked { @ViewChild('myTmpl') myTmpl!: TemplateRef; name: string = 'world'; - constructor(readonly changeDetector: ChangeDetectorRef) { - if (!(this instanceof OnPushDeclareComp)) { - declareComp = this; - } - } + constructor(readonly changeDetector: ChangeDetectorRef) {} ngDoCheck(): void { logValue = 'Declare'; } logName() { // This will log when the embedded view gets CD. The `logValue` will show if the CD was // from `Insert` or from `Declare` component. - log.push(logValue!); + viewExecutionLog.push(logValue!); return this.name; } ngAfterViewChecked(): void { logValue = null; } + ngAfterViewInit() { + templateRef = this.myTmpl; + } + } + + @Component({ + selector: `check-always-declare-comp`, + template: ` + DeclareComp({{name}}) + + {{greeting}} {{logName()}}! + + ` + }) + class CheckAlwaysDeclareComp extends DeclareComp { + constructor(changeDetector: ChangeDetectorRef) { + super(changeDetector); + declareComp = this; + } } @Component({ @@ -98,8 +88,7 @@ describe('change detection for transplanted views', () => { OnPushDeclareComp({{name}}) {{greeting}} {{logName()}}! - - `, + `, changeDetection: ChangeDetectionStrategy.OnPush }) class OnPushDeclareComp extends DeclareComp { @@ -109,180 +98,280 @@ describe('change detection for transplanted views', () => { } } + @Component({ + selector: `signal-onpush-declare-comp`, + template: ` + SignalOnPushDeclareComp({{name()}}) + + {{greeting}} {{surname()}}{{logExecutionContext()}}! + + `, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class SignalOnPushDeclareComp { + @ViewChild('myTmpl') myTmpl!: TemplateRef; + + name = signal('world'); + templateName = signal('templateName'); + + surname = computed(() => { + const name = this.templateName(); + return name; + }); + + logExecutionContext() { + viewExecutionLog.push(logValue); + return ''; + } + + constructor() { + signalDeclareComp = this; + } + + ngAfterViewChecked() { + logValue = null; + } + ngAfterViewInit() { + templateRef = this.myTmpl; + } + } @Component({ template: ` - - - - + + + + + ` }) class AppComp { - showDeclare: boolean = false; - showOnPushDeclare: boolean = false; - showInsert: boolean = false; - showInsertForOnPushDeclare: boolean = false; + showCheckAlwaysDeclare = false; + showSignalOnPushDeclare = false; + showOnPushDeclare = false; + showOnPushInsert = false; constructor() { appComp = this; } } - let log!: Array; + let viewExecutionLog!: Array; let logValue!: string|null; let fixture!: ComponentFixture; let appComp!: AppComp; - let insertComp!: InsertComp; - let insertForOnPushDeclareComp!: InsertForOnPushDeclareComp; - let declareComp!: DeclareComp; + let onPushInsertComp!: OnPushInsertComp; + let declareComp!: CheckAlwaysDeclareComp; + let templateRef: TemplateRef; let onPushDeclareComp!: OnPushDeclareComp; + let signalDeclareComp!: SignalOnPushDeclareComp; beforeEach(() => { TestBed.configureTestingModule({ - declarations: - [InsertComp, DeclareComp, OnPushDeclareComp, InsertForOnPushDeclareComp, AppComp], + declarations: [ + OnPushInsertComp, SignalOnPushDeclareComp, CheckAlwaysDeclareComp, OnPushDeclareComp, + AppComp + ], imports: [CommonModule], }); - log = []; + viewExecutionLog = []; fixture = TestBed.createComponent(AppComp); }); + describe('and declaration component is Onpush with signals and insertion is OnPush', () => { + beforeEach(() => { + fixture.componentInstance.showSignalOnPushDeclare = true; + fixture.componentInstance.showOnPushInsert = true; + fixture.detectChanges(false); + viewExecutionLog.length = 0; + }); + + it('should set up the component under test correctly', () => { + expect(viewExecutionLog.length).toEqual(0); + expect(trim(fixture.nativeElement.textContent)) + .toEqual('SignalOnPushDeclareComp(world) OnPushInsertComp(Hello) Hello templateName!'); + }); + + it('should CD at insertion and declaration', () => { + signalDeclareComp.name.set('Angular'); + fixture.detectChanges(false); + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)) + .withContext( + 'CD did not run on the transplanted template because it is inside an OnPush component and no signal changed') + .toEqual( + 'SignalOnPushDeclareComp(Angular) OnPushInsertComp(Hello) Hello templateName!'); + + onPushInsertComp.greeting = 'Hi'; + fixture.detectChanges(false); + expect(viewExecutionLog).toEqual([]); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)) + .withContext('Insertion component is OnPush.') + .toEqual( + 'SignalOnPushDeclareComp(Angular) OnPushInsertComp(Hello) Hello templateName!'); + + onPushInsertComp.changeDetectorRef.markForCheck(); + fixture.detectChanges(false); + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)) + .toEqual('SignalOnPushDeclareComp(Angular) OnPushInsertComp(Hi) Hi templateName!'); + + // Destroy insertion should also destroy declaration + appComp.showOnPushInsert = false; + fixture.detectChanges(false); + expect(viewExecutionLog).toEqual([]); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)).toEqual('SignalOnPushDeclareComp(Angular)'); + + // Restore both + appComp.showOnPushInsert = true; + fixture.detectChanges(false); + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)) + .toEqual( + 'SignalOnPushDeclareComp(Angular) OnPushInsertComp(Hello) Hello templateName!'); + }); + }); + describe('and declaration component is CheckAlways', () => { beforeEach(() => { - fixture.componentInstance.showDeclare = true; - fixture.componentInstance.showInsert = true; + fixture.componentInstance.showCheckAlwaysDeclare = true; + fixture.componentInstance.showOnPushInsert = true; fixture.detectChanges(false); - log.length = 0; + viewExecutionLog.length = 0; }); it('should set up the component under test correctly', () => { - expect(log.length).toEqual(0); + expect(viewExecutionLog.length).toEqual(0); expect(trim(fixture.nativeElement.textContent)) - .toEqual('DeclareComp(world) InsertComp(Hello) Hello world!'); + .toEqual('DeclareComp(world) OnPushInsertComp(Hello) Hello world!'); }); it('should CD at insertion point only', () => { declareComp.name = 'Angular'; fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) .toEqual( - 'DeclareComp(Angular) InsertComp(Hello) Hello Angular!', + 'DeclareComp(Angular) OnPushInsertComp(Hello) Hello Angular!', 'Expect transplanted LView to be CD because the declaration is CD.'); - insertComp.greeting = 'Hi'; + onPushInsertComp.greeting = 'Hi'; fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) .toEqual( - 'DeclareComp(Angular) InsertComp(Hello) Hello Angular!', + 'DeclareComp(Angular) OnPushInsertComp(Hello) Hello Angular!', 'expect no change because it is on push.'); - insertComp.changeDetectorRef.markForCheck(); + onPushInsertComp.changeDetectorRef.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('DeclareComp(Angular) InsertComp(Hi) Hi Angular!'); + .toEqual('DeclareComp(Angular) OnPushInsertComp(Hi) Hi Angular!'); // Destroy insertion should also destroy declaration - appComp.showInsert = false; + appComp.showOnPushInsert = false; fixture.detectChanges(false); - expect(log).toEqual([]); - log.length = 0; + expect(viewExecutionLog).toEqual([]); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)).toEqual('DeclareComp(Angular)'); // Restore both - appComp.showInsert = true; + appComp.showOnPushInsert = true; fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('DeclareComp(Angular) InsertComp(Hello) Hello Angular!'); + .toEqual('DeclareComp(Angular) OnPushInsertComp(Hello) Hello Angular!'); // Destroy declaration, But we should still be able to see updates in insertion - appComp.showDeclare = false; - insertComp.greeting = 'Hello'; - insertComp.changeDetectorRef.markForCheck(); + appComp.showCheckAlwaysDeclare = false; + onPushInsertComp.greeting = 'Hello'; + onPushInsertComp.changeDetectorRef.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; - expect(trim(fixture.nativeElement.textContent)).toEqual('InsertComp(Hello) Hello Angular!'); + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; + expect(trim(fixture.nativeElement.textContent)) + .toEqual('OnPushInsertComp(Hello) Hello Angular!'); }); it('is not checked if detectChanges is called in declaration component', () => { declareComp.name = 'Angular'; declareComp.changeDetector.detectChanges(); - expect(log).toEqual([]); - log.length = 0; + expect(viewExecutionLog).toEqual([]); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('DeclareComp(Angular) InsertComp(Hello) Hello world!'); + .toEqual('DeclareComp(Angular) OnPushInsertComp(Hello) Hello world!'); }); it('is checked as part of CheckNoChanges pass', () => { fixture.detectChanges(true); - expect(log).toEqual(['Insert', null /* logName set to null afterViewChecked */]); - log.length = 0; + expect(viewExecutionLog) + .toEqual(['Insert', null /* logName set to null afterViewChecked */]); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('DeclareComp(world) InsertComp(Hello) Hello world!'); + .toEqual('DeclareComp(world) OnPushInsertComp(Hello) Hello world!'); }); }); - describe('and declaration component is OnPush', () => { + describe('and declaration and insertion components are OnPush', () => { beforeEach(() => { fixture.componentInstance.showOnPushDeclare = true; - fixture.componentInstance.showInsertForOnPushDeclare = true; + fixture.componentInstance.showOnPushInsert = true; fixture.detectChanges(false); - log.length = 0; + viewExecutionLog.length = 0; }); it('should set up component under test correctly', () => { - expect(log.length).toEqual(0); + expect(viewExecutionLog.length).toEqual(0); expect(trim(fixture.nativeElement.textContent)) - .toEqual('OnPushDeclareComp(world) InsertComp(Hello) Hello world!'); + .toEqual('OnPushDeclareComp(world) OnPushInsertComp(Hello) Hello world!'); }); - it('should not check anything no views are dirty', () => { + it('should not check anything when no views are dirty', () => { fixture.detectChanges(false); - expect(log).toEqual([]); + expect(viewExecutionLog).toEqual([]); }); it('should CD at insertion point only', () => { onPushDeclareComp.name = 'Angular'; - insertForOnPushDeclareComp.greeting = 'Hi'; + onPushInsertComp.greeting = 'Hi'; // mark declaration point dirty onPushDeclareComp.changeDetector.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('OnPushDeclareComp(Angular) InsertComp(Hello) Hello Angular!'); + .toEqual('OnPushDeclareComp(Angular) OnPushInsertComp(Hello) Hello Angular!'); // mark insertion point dirty - insertForOnPushDeclareComp.changeDetectorRef.markForCheck(); + onPushInsertComp.changeDetectorRef.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('OnPushDeclareComp(Angular) InsertComp(Hi) Hi Angular!'); + .toEqual('OnPushDeclareComp(Angular) OnPushInsertComp(Hi) Hi Angular!'); // mark both insertion and declaration point dirty - insertForOnPushDeclareComp.changeDetectorRef.markForCheck(); + onPushInsertComp.changeDetectorRef.markForCheck(); onPushDeclareComp.changeDetector.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert']); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert']); + viewExecutionLog.length = 0; }); - it('is not checked if detectChanges is called in declaration component', () => { + it('is checked if detectChanges is called in declaration component', () => { onPushDeclareComp.name = 'Angular'; onPushDeclareComp.changeDetector.detectChanges(); - expect(log).toEqual([]); - log.length = 0; expect(trim(fixture.nativeElement.textContent)) - .toEqual('OnPushDeclareComp(Angular) InsertComp(Hello) Hello world!'); + .toEqual('OnPushDeclareComp(Angular) OnPushInsertComp(Hello) Hello world!'); }); // TODO(FW-1774): blocked by https://github.com/angular/angular/pull/34443 @@ -290,21 +379,22 @@ describe('change detection for transplanted views', () => { // mark declaration point dirty onPushDeclareComp.changeDetector.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert', null /* logName set to null in afterViewChecked */]); - log.length = 0; + expect(viewExecutionLog) + .toEqual(['Insert', null /* logName set to null in afterViewChecked */]); + viewExecutionLog.length = 0; // mark insertion point dirty - insertForOnPushDeclareComp.changeDetectorRef.markForCheck(); + onPushInsertComp.changeDetectorRef.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert', null]); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert', null]); + viewExecutionLog.length = 0; // mark both insertion and declaration point dirty - insertForOnPushDeclareComp.changeDetectorRef.markForCheck(); + onPushInsertComp.changeDetectorRef.markForCheck(); onPushDeclareComp.changeDetector.markForCheck(); fixture.detectChanges(false); - expect(log).toEqual(['Insert', null]); - log.length = 0; + expect(viewExecutionLog).toEqual(['Insert', null]); + viewExecutionLog.length = 0; }); it('does not cause infinite change detection if transplanted view is dirty and destroyed before refresh', @@ -312,13 +402,13 @@ describe('change detection for transplanted views', () => { // mark declaration point dirty onPushDeclareComp.changeDetector.markForCheck(); // detach insertion so the transplanted view doesn't get refreshed when CD runs - insertForOnPushDeclareComp.changeDetectorRef.detach(); + onPushInsertComp.changeDetectorRef.detach(); // run CD, which will set the `RefreshView` flag on the transplanted view fixture.detectChanges(false); // reattach insertion so the DESCENDANT_VIEWS counters update - insertForOnPushDeclareComp.changeDetectorRef.reattach(); + onPushInsertComp.changeDetectorRef.reattach(); // make it so the insertion is destroyed before getting refreshed - fixture.componentInstance.showInsertForOnPushDeclare = false; + fixture.componentInstance.showOnPushInsert = false; // run CD again. If we didn't clear the flag/counters when destroying the view, this // would cause an infinite CD because the counters will be >1 but we will never reach a // view to refresh and decrement the counters. @@ -614,9 +704,9 @@ describe('change detection for transplanted views', () => { constructor( readonly rootViewContainerRef: ViewContainerRef, readonly cdr: ChangeDetectorRef) {} - templateExecutions = 0; + checks = 0; incrementChecks() { - this.templateExecutions++; + this.checks++; } } @@ -639,13 +729,13 @@ describe('change detection for transplanted views', () => { component.cdr.detectChanges(); // The template should not have been refreshed because it was inserted "above" the component // so `detectChanges` will not refresh it. - expect(component.templateExecutions).toEqual(0); + expect(component.checks).toEqual(0); // Detach view, manually call `detectChanges`, and verify the template was refreshed component.rootViewContainerRef.detach(); viewRef.detectChanges(); // This view is a backwards reference so it's refreshed twice - expect(component.templateExecutions).toEqual(2); + expect(component.checks).toEqual(2); }); it('should work when change detecting detached transplanted view already marked for refresh', @@ -662,7 +752,7 @@ describe('change detection for transplanted views', () => { viewRef.detectChanges(); }).not.toThrow(); // This view is a backwards reference so it's refreshed twice - expect(component.templateExecutions).toEqual(2); + expect(component.checks).toEqual(2); }); it('should work when re-inserting a previously detached transplanted view marked for refresh', @@ -685,7 +775,7 @@ describe('change detection for transplanted views', () => { // The transplanted view gets refreshed twice because it's actually inserted "backwards" // The view is defined in AppComponent but inserted in its ViewContainerRef (as an // embedded view in AppComponent's host view). - expect(component.templateExecutions).toEqual(2); + expect(component.checks).toEqual(2); }); it('should work when detaching an attached transplanted view with the refresh flag', () => { From 225914b75ef038a6e97885b87571e90a82e9e181 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 25 Oct 2023 15:51:40 +0200 Subject: [PATCH 19/52] refactor(core): use performance API for control flow and standalone (#52370) This commit adds a standard performance marker that can be viewed in Chrome dev tools and other tooling. See more info at https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark PR Close #52370 --- packages/core/src/render3/features/standalone_feature.ts | 5 +++++ packages/core/src/render3/instructions/control_flow.ts | 8 ++++++++ .../animations-standalone/bundle.golden_symbols.json | 3 +++ .../core/test/bundling/defer/bundle.golden_symbols.json | 3 +++ .../test/bundling/hydration/bundle.golden_symbols.json | 3 +++ .../core/test/bundling/router/bundle.golden_symbols.json | 3 +++ .../standalone_bootstrap/bundle.golden_symbols.json | 3 +++ 7 files changed, 28 insertions(+) diff --git a/packages/core/src/render3/features/standalone_feature.ts b/packages/core/src/render3/features/standalone_feature.ts index e847b92444a01..aa1f01af32eac 100644 --- a/packages/core/src/render3/features/standalone_feature.ts +++ b/packages/core/src/render3/features/standalone_feature.ts @@ -60,6 +60,10 @@ class StandaloneService implements OnDestroy { }); } +const PERF_MARK_STANDALONE = { + detail: {feature: 'NgStandalone'} +}; + /** * A feature that acts as a setup code for the {@link StandaloneService}. * @@ -71,6 +75,7 @@ class StandaloneService implements OnDestroy { * @codeGenApi */ export function ɵɵStandaloneFeature(definition: ComponentDef) { + performance.mark('mark_use_counter', PERF_MARK_STANDALONE); definition.getStandaloneInjector = (parentInjector: EnvironmentInjector) => { return parentInjector.get(StandaloneService).getOrCreateStandaloneInjector(definition); }; diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index b92e6daa690a4..78fa79006f284 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -24,6 +24,10 @@ import {addLViewToLContainer, createAndRenderEmbeddedLView, getLViewFromLContain import {ɵɵtemplate} from './template'; +const PERF_MARK_CONTROL_FLOW = { + detail: {feature: 'NgControlFlow'} +}; + /** * The conditional instruction represents the basic building block on the runtime side to support * built-in "if" and "switch". On the high level this instruction is responsible for adding and @@ -36,6 +40,8 @@ import {ɵɵtemplate} from './template'; * @codeGenApi */ export function ɵɵconditional(containerIndex: number, matchingTemplateIndex: number, value?: T) { + performance.mark('mark_use_counter', PERF_MARK_CONTROL_FLOW); + const hostLView = getLView(); const bindingIndex = nextBindingIndex(); const lContainer = getLContainer(hostLView, HEADER_OFFSET + containerIndex); @@ -132,6 +138,8 @@ export function ɵɵrepeaterCreate( index: number, templateFn: ComponentTemplate, decls: number, vars: number, trackByFn: TrackByFunction, trackByUsesComponentInstance?: boolean, emptyTemplateFn?: ComponentTemplate, emptyDecls?: number, emptyVars?: number): void { + performance.mark('mark_use_counter', PERF_MARK_CONTROL_FLOW); + const hasEmptyBlock = emptyTemplateFn !== undefined; const hostLView = getLView(); const boundTrackBy = trackByUsesComponentInstance ? diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index ae10444c53209..e7f432226bb03 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -407,6 +407,9 @@ { "name": "PARAM_REGEX" }, + { + "name": "PERF_MARK_STANDALONE" + }, { "name": "PLATFORM_DESTROY_LISTENERS" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 7953c9c8a7304..686a3be5804ef 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -374,6 +374,9 @@ { "name": "Observable" }, + { + "name": "PERF_MARK_STANDALONE" + }, { "name": "PLATFORM_DESTROY_LISTENERS" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 09f5b952b7eb7..0cb24e7aab597 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -380,6 +380,9 @@ { "name": "Observable" }, + { + "name": "PERF_MARK_STANDALONE" + }, { "name": "PLATFORM_DESTROY_LISTENERS" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 5b0098dd17ea6..cbda420a4ae56 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -536,6 +536,9 @@ { "name": "OutletInjector" }, + { + "name": "PERF_MARK_STANDALONE" + }, { "name": "PLATFORM_DESTROY_LISTENERS" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 7d8287a8ef77f..53f4a0f630ea9 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -296,6 +296,9 @@ { "name": "Observable" }, + { + "name": "PERF_MARK_STANDALONE" + }, { "name": "PLATFORM_DESTROY_LISTENERS" }, From 6c507e75a65b8231fe55e7a015f137b9a6dac9e0 Mon Sep 17 00:00:00 2001 From: Dylan Hunn Date: Wed, 25 Oct 2023 13:24:04 -0700 Subject: [PATCH 20/52] refactor(compiler): Implement defer conditions, and change the way slots are linked (#52387) Previously, we had an "empty shell" implementation of defer conditions, and we used separate ops to represent secondary defer blocks. Now, we have a real scaffolding for supporting the various defer conditions, and the secondary defer block information has been refactored onto the main defer op. Additionally, to enable this, we refactor the way that using slot indices works. Instead of having a trait that causes users of slot indices to be linked to the allocated slot, we share a single `SlotHandle` object by reference. This allows an op to use slot information for more than one Xref at a time, and eliminates a layer of indirection. Co-authored-by: Alex Rickabaugh PR Close #52387 --- .../r3_view_compiler_deferred/TEST_CASES.json | 6 +- .../src/template/pipeline/ir/index.ts | 1 + .../src/template/pipeline/ir/src/enums.ts | 17 ++ .../template/pipeline/ir/src/expression.ts | 61 +++---- .../src/template/pipeline/ir/src/handle.ts | 11 ++ .../template/pipeline/ir/src/ops/create.ts | 156 +++++++++++------- .../template/pipeline/ir/src/ops/update.ts | 37 +++-- .../src/template/pipeline/ir/src/traits.ts | 53 +----- .../src/template/pipeline/src/ingest.ts | 97 ++++++----- .../src/template/pipeline/src/instruction.ts | 19 ++- .../src/phases/apply_i18n_expressions.ts | 2 +- .../phases/assign_i18n_slot_dependencies.ts | 2 +- .../pipeline/src/phases/conditionals.ts | 6 +- .../src/phases/defer_resolve_targets.ts | 97 +++++++++++ .../pipeline/src/phases/generate_advance.ts | 4 +- .../pipeline/src/phases/generate_variables.ts | 7 +- .../src/phases/i18n_text_extraction.ts | 15 +- .../pipeline/src/phases/icu_extraction.ts | 14 +- .../template/pipeline/src/phases/naming.ts | 15 +- .../template/pipeline/src/phases/ordering.ts | 2 +- .../pipeline/src/phases/pipe_creation.ts | 20 +-- .../pipeline/src/phases/pipe_variadic.ts | 2 +- .../src/template/pipeline/src/phases/reify.ts | 74 +++++---- .../resolve_i18n_element_placeholders.ts | 8 +- .../pipeline/src/phases/slot_allocation.ts | 38 +---- 25 files changed, 443 insertions(+), 321 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/ir/src/handle.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json index 3cbed511bcc4f..e2066f6603445 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json @@ -16,7 +16,8 @@ ], "failureMessage": "Incorrect template" } - ] + ], + "skipForTemplatePipeline": true }, { "description": "should generate a deferred block with secondary blocks", @@ -33,7 +34,8 @@ ], "failureMessage": "Incorrect template" } - ] + ], + "skipForTemplatePipeline": true }, { "description": "should generate a deferred block with placeholder block parameters", diff --git a/packages/compiler/src/template/pipeline/ir/index.ts b/packages/compiler/src/template/pipeline/ir/index.ts index c3b3262d70dea..b198b358c1b22 100644 --- a/packages/compiler/src/template/pipeline/ir/index.ts +++ b/packages/compiler/src/template/pipeline/ir/index.ts @@ -13,5 +13,6 @@ export * from './src/ops/create'; export * from './src/ops/host'; export * from './src/ops/shared'; export * from './src/ops/update'; +export * from './src/handle'; export * from './src/traits'; export * from './src/variable'; diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 2d006549e1b97..4608ea11b49b3 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -507,3 +507,20 @@ export enum I18nParamValueFlags { */ CloseTag = 0b1000, } + +/** + * Whether the active namespace is HTML, MathML, or SVG mode. + */ +export enum Namespace { + HTML, + SVG, + Math, +} + +/** + * The type of a `@defer` trigger, for use in the ir. + */ +export enum DeferTriggerKind { + Idle, + Interaction, +} diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index ed79fb01e148e..99a8c64f2aaba 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -11,8 +11,8 @@ import type {ParseSourceSpan} from '../../../../parse_util'; import * as t from '../../../../render3/r3_ast'; import {ExpressionKind, OpKind, SanitizerFn} from './enums'; -import {ConsumesVarsTrait, UsesSlotIndex, UsesSlotIndexTrait, UsesVarOffset, UsesVarOffsetTrait} from './traits'; - +import {ConsumesVarsTrait, UsesVarOffset, UsesVarOffsetTrait} from './traits'; +import {SlotHandle} from './handle'; import type {XrefId} from './operations'; import type {CreateOp} from './ops/create'; import {Interpolation, type UpdateOp} from './ops/update'; @@ -90,14 +90,10 @@ export class LexicalReadExpr extends ExpressionBase { /** * Runtime operation to retrieve the value of a local reference. */ -export class ReferenceExpr extends ExpressionBase implements UsesSlotIndexTrait { +export class ReferenceExpr extends ExpressionBase { override readonly kind = ExpressionKind.Reference; - readonly[UsesSlotIndex] = true; - - targetSlot: number|null = null; - - constructor(readonly target: XrefId, readonly offset: number) { + constructor(readonly target: XrefId, readonly targetSlot: SlotHandle, readonly offset: number) { super(); } @@ -114,9 +110,7 @@ export class ReferenceExpr extends ExpressionBase implements UsesSlotIndexTrait override transformInternalExpressions(): void {} override clone(): ReferenceExpr { - const expr = new ReferenceExpr(this.target, this.offset); - expr.targetSlot = this.targetSlot; - return expr; + return new ReferenceExpr(this.target, this.targetSlot, this.offset); } } @@ -442,18 +436,17 @@ export class PureFunctionParameterExpr extends ExpressionBase { } } -export class PipeBindingExpr extends ExpressionBase implements UsesSlotIndexTrait, - ConsumesVarsTrait, +export class PipeBindingExpr extends ExpressionBase implements ConsumesVarsTrait, UsesVarOffsetTrait { override readonly kind = ExpressionKind.PipeBinding; - readonly[UsesSlotIndex] = true; readonly[ConsumesVarsTrait] = true; readonly[UsesVarOffset] = true; - targetSlot: number|null = null; varOffset: number|null = null; - constructor(readonly target: XrefId, readonly name: string, readonly args: o.Expression[]) { + constructor( + readonly target: XrefId, readonly targetSlot: SlotHandle, readonly name: string, + readonly args: o.Expression[]) { super(); } @@ -479,27 +472,24 @@ export class PipeBindingExpr extends ExpressionBase implements UsesSlotIndexTrai } override clone() { - const r = new PipeBindingExpr(this.target, this.name, this.args.map(a => a.clone())); - r.targetSlot = this.targetSlot; + const r = + new PipeBindingExpr(this.target, this.targetSlot, this.name, this.args.map(a => a.clone())); r.varOffset = this.varOffset; return r; } } -export class PipeBindingVariadicExpr extends ExpressionBase implements UsesSlotIndexTrait, - ConsumesVarsTrait, +export class PipeBindingVariadicExpr extends ExpressionBase implements ConsumesVarsTrait, UsesVarOffsetTrait { override readonly kind = ExpressionKind.PipeBindingVariadic; - readonly[UsesSlotIndex] = true; readonly[ConsumesVarsTrait] = true; readonly[UsesVarOffset] = true; - targetSlot: number|null = null; varOffset: number|null = null; constructor( - readonly target: XrefId, readonly name: string, public args: o.Expression, - public numArgs: number) { + readonly target: XrefId, readonly targetSlot: SlotHandle, readonly name: string, + public args: o.Expression, public numArgs: number) { super(); } @@ -521,8 +511,8 @@ export class PipeBindingVariadicExpr extends ExpressionBase implements UsesSlotI } override clone(): PipeBindingVariadicExpr { - const r = new PipeBindingVariadicExpr(this.target, this.name, this.args.clone(), this.numArgs); - r.targetSlot = this.targetSlot; + const r = new PipeBindingVariadicExpr( + this.target, this.targetSlot, this.name, this.args.clone(), this.numArgs); r.varOffset = this.varOffset; return r; } @@ -766,21 +756,17 @@ export class SanitizerExpr extends ExpressionBase { override transformInternalExpressions(): void {} } -export class SlotLiteralExpr extends ExpressionBase implements UsesSlotIndexTrait { +export class SlotLiteralExpr extends ExpressionBase { override readonly kind = ExpressionKind.SlotLiteralExpr; - readonly[UsesSlotIndex] = true; - constructor(readonly target: XrefId) { + constructor(readonly slot: SlotHandle) { super(); } - targetSlot: number|null = null; - override visitExpression(visitor: o.ExpressionVisitor, context: any): any {} override isEquivalent(e: Expression): boolean { - return e instanceof SlotLiteralExpr && e.target === this.target && - e.targetSlot === this.targetSlot; + return e instanceof SlotLiteralExpr && e.slot === this.slot; } override isConstant() { @@ -788,9 +774,7 @@ export class SlotLiteralExpr extends ExpressionBase implements UsesSlotIndexTrai } override clone(): SlotLiteralExpr { - const copy = new SlotLiteralExpr(this.target); - copy.targetSlot = this.targetSlot; - return copy; + return new SlotLiteralExpr(this.slot); } override transformInternalExpressions(): void {} @@ -805,7 +789,7 @@ export class ConditionalCaseExpr extends ExpressionBase { * @param target The Xref of the view to be displayed if this condition is true. */ constructor( - public expr: o.Expression|null, readonly target: XrefId, + public expr: o.Expression|null, readonly target: XrefId, readonly targetSlot: SlotHandle, readonly alias: t.Variable|null = null) { super(); } @@ -825,7 +809,7 @@ export class ConditionalCaseExpr extends ExpressionBase { } override clone(): ConditionalCaseExpr { - return new ConditionalCaseExpr(this.expr, this.target); + return new ConditionalCaseExpr(this.expr, this.target, this.targetSlot); } override transformInternalExpressions(transform: ExpressionTransform, flags: VisitorContextFlag): @@ -975,7 +959,6 @@ export function transformExpressionsInOp( case OpKind.ContainerStart: case OpKind.Defer: case OpKind.DeferOn: - case OpKind.DeferSecondaryBlock: case OpKind.DisableBindings: case OpKind.Element: case OpKind.ElementEnd: diff --git a/packages/compiler/src/template/pipeline/ir/src/handle.ts b/packages/compiler/src/template/pipeline/ir/src/handle.ts new file mode 100644 index 0000000000000..cfcbd6ec7475a --- /dev/null +++ b/packages/compiler/src/template/pipeline/ir/src/handle.ts @@ -0,0 +1,11 @@ +/** + * @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 + */ + +export class SlotHandle { + slot: number|null = null; +} diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index e1af45a63d8f9..519de569ebba4 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -9,9 +9,10 @@ import * as i18n from '../../../../../i18n/i18n_ast'; import * as o from '../../../../../output/output_ast'; import {ParseSourceSpan} from '../../../../../parse_util'; -import {BindingKind, DeferSecondaryKind, I18nParamValueFlags, OpKind} from '../enums'; +import {BindingKind, DeferTriggerKind, I18nParamValueFlags, Namespace, OpKind} from '../enums'; +import {SlotHandle} from '../handle'; import {Op, OpList, XrefId} from '../operations'; -import {ConsumesSlotOpTrait, HasConstTrait, TRAIT_CONSUMES_SLOT, TRAIT_HAS_CONST, TRAIT_USES_SLOT_INDEX, UsesSlotIndexTrait} from '../traits'; +import {ConsumesSlotOpTrait, TRAIT_CONSUMES_SLOT} from '../traits'; import {ListEndOp, NEW_OP, StatementOp, VariableOp} from './shared'; @@ -20,11 +21,11 @@ import type {UpdateOp} from './update'; /** * An operation usable on the creation side of the IR. */ -export type CreateOp = ListEndOp|StatementOp|ElementOp|ElementStartOp| - ElementEndOp|ContainerOp|ContainerStartOp|ContainerEndOp|TemplateOp|EnableBindingsOp| - DisableBindingsOp|TextOp|ListenerOp|PipeOp|VariableOp|NamespaceOp|ProjectionDefOp| - ProjectionOp|ExtractedAttributeOp|DeferOp|DeferSecondaryBlockOp|DeferOnOp|RepeaterCreateOp| - ExtractedMessageOp|I18nOp|I18nStartOp|I18nEndOp|IcuOp; +export type CreateOp = + ListEndOp|StatementOp|ElementOp|ElementStartOp|ElementEndOp|ContainerOp| + ContainerStartOp|ContainerEndOp|TemplateOp|EnableBindingsOp|DisableBindingsOp|TextOp|ListenerOp| + PipeOp|VariableOp|NamespaceOp|ProjectionDefOp|ProjectionOp|ExtractedAttributeOp| + DeferOp|DeferOnOp|RepeaterCreateOp|ExtractedMessageOp|I18nOp|I18nStartOp|I18nEndOp|IcuOp; /** * An operation representing the creation of an element or container. @@ -76,6 +77,8 @@ export interface ElementOrContainerOpBase extends Op, ConsumesSlotOpTr */ xref: XrefId; + slot: SlotHandle; + /** * Attributes of various kinds on this element. Represented as a `ConstIndex` pointer into the * shared `consts` array of the component compilation. @@ -137,6 +140,7 @@ export function createElementStartOp( kind: OpKind.ElementStart, xref, tag, + slot: new SlotHandle(), attributes: null, localRefs: [], nonBindable: false, @@ -202,6 +206,7 @@ export function createTemplateOp( xref, attributes: null, tag, + slot: new SlotHandle(), block: generatedInBlock, decls: null, vars: null, @@ -280,6 +285,7 @@ export function createRepeaterCreateOp( kind: OpKind.RepeaterCreate, attributes: null, xref: primaryView, + slot: new SlotHandle(), emptyView, track, trackByFn: null, @@ -424,6 +430,7 @@ export function createTextOp( return { kind: OpKind.Text, xref, + slot: new SlotHandle(), initialValue, sourceSpan, ...TRAIT_CONSUMES_SLOT, @@ -434,9 +441,12 @@ export function createTextOp( /** * Logical operation representing an event listener on an element in the creation IR. */ -export interface ListenerOp extends Op, UsesSlotIndexTrait { +export interface ListenerOp extends Op { kind: OpKind.Listener; + target: XrefId; + targetSlot: SlotHandle; + /** * Whether this listener is from a host binding. */ @@ -485,11 +495,12 @@ export interface ListenerOp extends Op, UsesSlotIndexTrait { * Create a `ListenerOp`. Host bindings reuse all the listener logic. */ export function createListenerOp( - target: XrefId, name: string, tag: string|null, animationPhase: string|null, - hostListener: boolean, sourceSpan: ParseSourceSpan): ListenerOp { + target: XrefId, targetSlot: SlotHandle, name: string, tag: string|null, + animationPhase: string|null, hostListener: boolean, sourceSpan: ParseSourceSpan): ListenerOp { return { kind: OpKind.Listener, target, + targetSlot, tag, hostListener, name, @@ -500,7 +511,6 @@ export function createListenerOp( animationPhase: animationPhase, sourceSpan, ...NEW_OP, - ...TRAIT_USES_SLOT_INDEX, }; } @@ -510,25 +520,17 @@ export interface PipeOp extends Op, ConsumesSlotOpTrait { name: string; } -export function createPipeOp(xref: XrefId, name: string): PipeOp { +export function createPipeOp(xref: XrefId, slot: SlotHandle, name: string): PipeOp { return { kind: OpKind.Pipe, xref, + slot, name, ...NEW_OP, ...TRAIT_CONSUMES_SLOT, }; } -/** - * Whether the active namespace is HTML, MathML, or SVG mode. - */ -export enum Namespace { - HTML, - SVG, - Math, -} - /** * An op corresponding to a namespace instruction, for switching between HTML, SVG, and MathML. */ @@ -571,8 +573,6 @@ export interface ProjectionOp extends Op, ConsumesSlotOpTrait { xref: XrefId; - slot: number|null; - projectionSlotIndex: number; attributes: string[]; @@ -589,6 +589,7 @@ export function createProjectionOp( return { kind: OpKind.Projection, xref, + slot: new SlotHandle(), selector, projectionSlotIndex: 0, attributes: [], @@ -642,7 +643,7 @@ export function createExtractedAttributeOp( }; } -export interface DeferOp extends Op, ConsumesSlotOpTrait, UsesSlotIndexTrait { +export interface DeferOp extends Op, ConsumesSlotOpTrait { kind: OpKind.Defer; /** @@ -651,91 +652,121 @@ export interface DeferOp extends Op, ConsumesSlotOpTrait, UsesSlotInde xref: XrefId; /** - * The xref of the main view. This will be associated with `slot`. + * The xref of the main view. */ - target: XrefId; + mainView: XrefId; + + mainSlot: SlotHandle; /** * Secondary loading block associated with this defer op. */ - loading: DeferSecondaryBlockOp|null; + loadingView: XrefId|null; + + loadingSlot: SlotHandle|null; /** * Secondary placeholder block associated with this defer op. */ - placeholder: DeferSecondaryBlockOp|null; + placeholderView: XrefId|null; + + placeholderSlot: SlotHandle|null; /** * Secondary error block associated with this defer op. */ - error: DeferSecondaryBlockOp|null; + errorView: XrefId|null; + + errorSlot: SlotHandle|null; sourceSpan: ParseSourceSpan; } -export function createDeferOp(xref: XrefId, main: XrefId, sourceSpan: ParseSourceSpan): DeferOp { +export function createDeferOp( + xref: XrefId, main: XrefId, mainSlot: SlotHandle, placeholderView: XrefId|null, + placeholderSlot: SlotHandle|null, sourceSpan: ParseSourceSpan): DeferOp { return { kind: OpKind.Defer, xref, - target: main, - loading: null, - placeholder: null, - error: null, + slot: new SlotHandle(), + mainView: main, + mainSlot, + loadingView: null, + loadingSlot: null, + placeholderView: null, + placeholderSlot, + errorView: null, + errorSlot: null, sourceSpan, ...NEW_OP, ...TRAIT_CONSUMES_SLOT, - ...TRAIT_USES_SLOT_INDEX, }; } +interface DeferTriggerBase { + kind: DeferTriggerKind; +} + +interface DeferIdleTrigger extends DeferTriggerBase { + kind: DeferTriggerKind.Idle; +} + +interface DeferInteractionTrigger extends DeferTriggerBase { + kind: DeferTriggerKind.Interaction; -export interface DeferSecondaryBlockOp extends Op, UsesSlotIndexTrait, HasConstTrait { - kind: OpKind.DeferSecondaryBlock; + targetName: string|null; /** - * The xref of the corresponding defer op. + * The Xref of the targeted name. May be in a different view. */ - deferOp: XrefId; + targetXref: XrefId|null; /** - * Which kind of secondary block this op represents. + * The slot index of the named reference, inside the view provided below. This slot may not be + * inside the current view, and is handled specially as a result. */ - secondaryBlockKind: DeferSecondaryKind; + targetSlot: number|null; + + targetView: XrefId|null; /** - * The xref of the secondary view. This will be associated with `slot`. + * Number of steps to walk up or down the view tree to find the target localRef. */ - target: XrefId; + targetSlotViewSteps: number|null; } -export function createDeferSecondaryOp( - deferOp: XrefId, secondaryView: XrefId, - secondaryBlockKind: DeferSecondaryKind): DeferSecondaryBlockOp { - return { - kind: OpKind.DeferSecondaryBlock, - deferOp, - target: secondaryView, - secondaryBlockKind, - constValue: null, - makeExpression: literalOrArrayLiteral, - ...NEW_OP, - ...TRAIT_USES_SLOT_INDEX, - ...TRAIT_HAS_CONST, - }; -} +/** + * The union type of all defer trigger interfaces. + */ +export type DeferTrigger = DeferIdleTrigger|DeferInteractionTrigger; -export interface DeferOnOp extends Op, ConsumesSlotOpTrait { +export interface DeferOnOp extends Op { kind: OpKind.DeferOn; + defer: XrefId; + + /** + * The trigger for this defer op (e.g. idle, hover, etc). + */ + trigger: DeferTrigger; + + /** + * Whether to emit the prefetch version of the instruction. + */ + prefetch: boolean; + sourceSpan: ParseSourceSpan; } -export function createDeferOnOp(xref: XrefId, sourceSpan: ParseSourceSpan): DeferOnOp { +export function createDeferOnOp( + defer: XrefId, trigger: DeferTrigger, prefetch: boolean, + sourceSpan: ParseSourceSpan): DeferOnOp { return { kind: OpKind.DeferOn, - xref, + defer, + trigger, + prefetch, sourceSpan, ...NEW_OP, - ...TRAIT_CONSUMES_SLOT, }; } @@ -882,6 +913,7 @@ export function createI18nStartOp(xref: XrefId, message: i18n.Message, root?: Xr return { kind: OpKind.I18nStart, xref, + slot: new SlotHandle(), root: root ?? xref, message, messageIndex: null, diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts index 28c23d7b21f53..5a33df733c09b 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts @@ -13,8 +13,8 @@ import {ParseSourceSpan} from '../../../../../parse_util'; import {BindingKind, I18nParamResolutionTime, OpKind} from '../enums'; import type {ConditionalCaseExpr} from '../expression'; import {Op, XrefId} from '../operations'; -import {ConsumesSlotOpTrait, ConsumesVarsTrait, DependsOnSlotContextOpTrait, TRAIT_CONSUMES_SLOT, TRAIT_CONSUMES_VARS, TRAIT_DEPENDS_ON_SLOT_CONTEXT, TRAIT_USES_SLOT_INDEX, UsesSlotIndexTrait} from '../traits'; - +import {ConsumesSlotOpTrait, ConsumesVarsTrait, DependsOnSlotContextOpTrait, TRAIT_CONSUMES_VARS, TRAIT_DEPENDS_ON_SLOT_CONTEXT} from '../traits'; +import {SlotHandle} from '../handle'; import type {HostPropertyOp} from './host'; import {ListEndOp, NEW_OP, StatementOp, VariableOp} from './shared'; @@ -474,7 +474,7 @@ export function createAdvanceOp(delta: number, sourceSpan: ParseSourceSpan): Adv * Logical operation representing a conditional expression in the update IR. */ export interface ConditionalOp extends Op, DependsOnSlotContextOpTrait, - UsesSlotIndexTrait, ConsumesVarsTrait { + ConsumesVarsTrait { kind: OpKind.Conditional; /** @@ -486,7 +486,7 @@ export interface ConditionalOp extends Op, DependsOnSlotContextOp /** * The slot of the target, to be populated during slot allocation. */ - targetSlot: number|null; + targetSlot: SlotHandle; /** * The main test expression (for a switch), or `null` (for an if, which has no test expression). @@ -518,24 +518,24 @@ export interface ConditionalOp extends Op, DependsOnSlotContextOp * Create a conditional op, which will display an embedded view according to a condtion. */ export function createConditionalOp( - target: XrefId, test: o.Expression|null, conditions: Array, - sourceSpan: ParseSourceSpan): ConditionalOp { + target: XrefId, targetSlot: SlotHandle, test: o.Expression|null, + conditions: Array, sourceSpan: ParseSourceSpan): ConditionalOp { return { kind: OpKind.Conditional, target, + targetSlot, test, conditions, processed: null, sourceSpan, contextValue: null, ...NEW_OP, - ...TRAIT_USES_SLOT_INDEX, ...TRAIT_DEPENDS_ON_SLOT_CONTEXT, ...TRAIT_CONSUMES_VARS, }; } -export interface RepeaterOp extends Op, UsesSlotIndexTrait { +export interface RepeaterOp extends Op { kind: OpKind.Repeater; /** @@ -543,6 +543,8 @@ export interface RepeaterOp extends Op, UsesSlotIndexTrait { */ target: XrefId; + targetSlot: SlotHandle; + /** * The collection provided to the for loop as its expression. */ @@ -552,14 +554,15 @@ export interface RepeaterOp extends Op, UsesSlotIndexTrait { } export function createRepeaterOp( - repeaterCreate: XrefId, collection: o.Expression, sourceSpan: ParseSourceSpan): RepeaterOp { + repeaterCreate: XrefId, targetSlot: SlotHandle, collection: o.Expression, + sourceSpan: ParseSourceSpan): RepeaterOp { return { kind: OpKind.Repeater, target: repeaterCreate, + targetSlot, collection, sourceSpan, ...NEW_OP, - ...TRAIT_USES_SLOT_INDEX, }; } @@ -575,6 +578,8 @@ export interface I18nExpressionOp extends Op, ConsumesVarsTrait, */ owner: XrefId; + ownerSlot: SlotHandle; + /** * The Xref of the op that we need to `advance` to. This should be the final op in the owning i18n * block. This is necessary so that we run all lifecycle hooks. @@ -605,11 +610,12 @@ export interface I18nExpressionOp extends Op, ConsumesVarsTrait, * Create an i18n expression op. */ export function createI18nExpressionOp( - owner: XrefId, expression: o.Expression, i18nPlaceholder: string, + owner: XrefId, ownerSlot: SlotHandle, expression: o.Expression, i18nPlaceholder: string, resolutionTime: I18nParamResolutionTime, sourceSpan: ParseSourceSpan): I18nExpressionOp { return { kind: OpKind.I18nExpression, owner, + ownerSlot, target: owner, expression, i18nPlaceholder, @@ -624,7 +630,7 @@ export function createI18nExpressionOp( /** * An op that represents applying a set of i18n expressions. */ -export interface I18nApplyOp extends Op, UsesSlotIndexTrait { +export interface I18nApplyOp extends Op { kind: OpKind.I18nApply; /** @@ -632,19 +638,22 @@ export interface I18nApplyOp extends Op, UsesSlotIndexTrait { */ target: XrefId; + targetSlot: SlotHandle; + sourceSpan: ParseSourceSpan; } /** *Creates an op to apply i18n expression ops */ -export function createI18nApplyOp(target: XrefId, sourceSpan: ParseSourceSpan): I18nApplyOp { +export function createI18nApplyOp( + target: XrefId, targetSlot: SlotHandle, sourceSpan: ParseSourceSpan): I18nApplyOp { return { kind: OpKind.I18nApply, target, + targetSlot, sourceSpan, ...NEW_OP, - ...TRAIT_USES_SLOT_INDEX, }; } diff --git a/packages/compiler/src/template/pipeline/ir/src/traits.ts b/packages/compiler/src/template/pipeline/ir/src/traits.ts index bf5b93cadde7f..3e594d40291da 100644 --- a/packages/compiler/src/template/pipeline/ir/src/traits.ts +++ b/packages/compiler/src/template/pipeline/ir/src/traits.ts @@ -10,6 +10,7 @@ import * as o from '../../../../output/output_ast'; import type {ParseSourceSpan} from '../../../../parse_util'; import type {Expression} from './expression'; import type {Op, XrefId} from './operations'; +import {SlotHandle} from './handle'; /** * Marker symbol for `ConsumesSlotOpTrait`. @@ -21,11 +22,6 @@ export const ConsumesSlot = Symbol('ConsumesSlot'); */ export const DependsOnSlotContext = Symbol('DependsOnSlotContext'); -/** - * Marker symbol for `UsesSlotIndex` trait. - */ -export const UsesSlotIndex = Symbol('UsesSlotIndex'); - /** * Marker symbol for `ConsumesVars` trait. */ @@ -51,7 +47,7 @@ export interface ConsumesSlotOpTrait { * Assigned data slot (the starting index, if more than one slot is needed) for this operation, or * `null` if slots have not yet been assigned. */ - slot: number|null; + slot: SlotHandle; /** * The number of slots which will be used by this operation. By default 1, but can be increased if @@ -89,29 +85,6 @@ export interface DependsOnSlotContextOpTrait { sourceSpan: ParseSourceSpan; } - -/** - * Marks an expression which requires knowledge of the assigned slot of a given - * `ConsumesSlotOpTrait` implementor (e.g. an element slot). - * - * During IR processing, assigned slots of `ConsumesSlotOpTrait` implementors will be propagated to - * `UsesSlotIndexTrait` implementors by matching their `XrefId`s. - */ -export interface UsesSlotIndexTrait { - readonly[UsesSlotIndex]: true; - - /** - * `XrefId` of the `ConsumesSlotOpTrait` which this expression needs to reference by its assigned - * slot index. - */ - target: XrefId; - - /** - * The slot index of `target`, or `null` if slots have not yet been assigned. - */ - targetSlot: number|null; -} - /** * Marker trait indicating that an operation or expression consumes variable storage space. */ @@ -157,21 +130,11 @@ export interface HasConstTrait { * Default values for most `ConsumesSlotOpTrait` fields (used with the spread operator to initialize * implementors of the trait). */ -export const TRAIT_CONSUMES_SLOT: Omit = { +export const TRAIT_CONSUMES_SLOT: Omit = { [ConsumesSlot]: true, - slot: null, numSlotsUsed: 1, } as const; -/** - * Default values for most `UsesSlotIndexTrait` fields (used with the spread operator to initialize - * implementors of the trait). - */ -export const TRAIT_USES_SLOT_INDEX: Omit = { - [UsesSlotIndex]: true, - targetSlot: null, -} as const; - /** * Default values for most `DependsOnSlotContextOpTrait` fields (used with the spread operator to * initialize implementors of the trait). @@ -240,16 +203,6 @@ export function hasUsesVarOffsetTrait(expr: ExprT): ex return (expr as Partial)[UsesVarOffset] === true; } -/** - * Test whether an operation or expression implements `UsesSlotIndexTrait`. - */ -export function hasUsesSlotIndexTrait(expr: ExprT): expr is ExprT& - UsesSlotIndexTrait; -export function hasUsesSlotIndexTrait>(op: OpT): op is OpT&UsesSlotIndexTrait; -export function hasUsesSlotIndexTrait(value: any): boolean { - return (value as Partial)[UsesSlotIndex] === true; -} - /** * Test whether an operation or expression implements `HasConstTrait`. */ diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index faf4288bed528..58fd47b403957 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -14,7 +14,6 @@ import {splitNsName} from '../../../ml_parser/tags'; import * as o from '../../../output/output_ast'; import {ParseSourceSpan} from '../../../parse_util'; import * as t from '../../../render3/r3_ast'; -import {Identifiers} from '../../../render3/r3_identifiers'; import {BindingParser} from '../../../template_parser/binding_parser'; import * as ir from '../ir'; @@ -102,7 +101,8 @@ export function ingestHostAttribute( export function ingestHostEvent(job: HostBindingCompilationJob, event: e.ParsedEvent) { const eventBinding = ir.createListenerOp( - job.root.xref, event.name, null, event.targetOrPhase, true, event.sourceSpan); + job.root.xref, new ir.SlotHandle(), event.name, null, event.targetOrPhase, true, + event.sourceSpan); // TODO: Can this be a chain? eventBinding.handlerOps.push(ir.createStatementOp(new o.ReturnStatement( convertAst(event.handler.ast, job, event.sourceSpan), event.handlerSpan))); @@ -279,25 +279,30 @@ function ingestBoundText(unit: ViewCompilationUnit, text: t.BoundText): void { */ function ingestIfBlock(unit: ViewCompilationUnit, ifBlock: t.IfBlock): void { let firstXref: ir.XrefId|null = null; + let firstSlotHandle: ir.SlotHandle|null = null; let conditions: Array = []; for (const ifCase of ifBlock.branches) { const cView = unit.job.allocateView(unit.xref); if (ifCase.expressionAlias !== null) { cView.contextVariables.set(ifCase.expressionAlias.name, ir.CTX_REF); } + const tmplOp = ir.createTemplateOp( + cView.xref, 'Conditional', ir.Namespace.HTML, true, + undefined /* TODO: figure out how i18n works with new control flow */, ifCase.sourceSpan); + unit.create.push(tmplOp); + if (firstXref === null) { firstXref = cView.xref; + firstSlotHandle = tmplOp.slot; } - unit.create.push(ir.createTemplateOp( - cView.xref, 'Conditional', ir.Namespace.HTML, true, - undefined /* TODO: figure out how i18n works with new control flow */, ifCase.sourceSpan)); const caseExpr = ifCase.expression ? convertAst(ifCase.expression, unit.job, null) : null; const conditionalCaseExpr = - new ir.ConditionalCaseExpr(caseExpr, cView.xref, ifCase.expressionAlias); + new ir.ConditionalCaseExpr(caseExpr, tmplOp.xref, tmplOp.slot, ifCase.expressionAlias); conditions.push(conditionalCaseExpr); ingestNodes(cView, ifCase.children); } - const conditional = ir.createConditionalOp(firstXref!, null, conditions, ifBlock.sourceSpan); + const conditional = + ir.createConditionalOp(firstXref!, firstSlotHandle!, null, conditions, ifBlock.sourceSpan); unit.update.push(conditional); } @@ -306,25 +311,28 @@ function ingestIfBlock(unit: ViewCompilationUnit, ifBlock: t.IfBlock): void { */ function ingestSwitchBlock(unit: ViewCompilationUnit, switchBlock: t.SwitchBlock): void { let firstXref: ir.XrefId|null = null; + let firstSlotHandle: ir.SlotHandle|null = null; let conditions: Array = []; for (const switchCase of switchBlock.cases) { const cView = unit.job.allocateView(unit.xref); + const tmplOp = ir.createTemplateOp( + cView.xref, 'Case', ir.Namespace.HTML, true, + undefined /* TODO: figure out how i18n works with new control flow */, + switchCase.sourceSpan); + unit.create.push(tmplOp); if (firstXref === null) { firstXref = cView.xref; + firstSlotHandle = tmplOp.slot; } - unit.create.push(ir.createTemplateOp( - cView.xref, 'Case', ir.Namespace.HTML, true, - undefined /* TODO: figure out how i18n works with new control flow */, - switchCase.sourceSpan)); const caseExpr = switchCase.expression ? convertAst(switchCase.expression, unit.job, switchBlock.startSourceSpan) : null; - const conditionalCaseExpr = new ir.ConditionalCaseExpr(caseExpr, cView.xref); + const conditionalCaseExpr = new ir.ConditionalCaseExpr(caseExpr, tmplOp.xref, tmplOp.slot); conditions.push(conditionalCaseExpr); ingestNodes(cView, switchCase.children); } const conditional = ir.createConditionalOp( - firstXref!, convertAst(switchBlock.expression, unit.job, null), conditions, + firstXref!, firstSlotHandle!, convertAst(switchBlock.expression, unit.job, null), conditions, switchBlock.sourceSpan); unit.update.push(conditional); } @@ -354,38 +362,41 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock ingestDeferView(unit, 'Error', deferBlock.error?.children, deferBlock.error?.sourceSpan); // Create the main defer op, and ops for all secondary views. - const deferOp = ir.createDeferOp(unit.job.allocateXrefId(), main.xref, deferBlock.sourceSpan); + const deferXref = unit.job.allocateXrefId(); + const deferOp = ir.createDeferOp( + deferXref, main.xref, main.slot, placeholder?.xref ?? null, placeholder?.slot ?? null, + deferBlock.sourceSpan); unit.create.push(deferOp); - if (loading && deferBlock.loading) { - deferOp.loading = - ir.createDeferSecondaryOp(deferOp.xref, loading.xref, ir.DeferSecondaryKind.Loading); - if (deferBlock.loading.afterTime !== null || deferBlock.loading.minimumTime !== null) { - deferOp.loading.constValue = [deferBlock.loading.minimumTime, deferBlock.loading.afterTime]; - } - unit.create.push(deferOp.loading); - } + // Configure all defer `on` conditions. - if (placeholder && deferBlock.placeholder) { - deferOp.placeholder = ir.createDeferSecondaryOp( - deferOp.xref, placeholder.xref, ir.DeferSecondaryKind.Placeholder); - if (deferBlock.placeholder.minimumTime !== null) { - deferOp.placeholder.constValue = [deferBlock.placeholder.minimumTime]; + let prefetch = false; + let deferOnOps: ir.DeferOnOp[] = []; + for (const triggers of [deferBlock.triggers, deferBlock.prefetchTriggers]) { + if (triggers.idle !== undefined) { + const deferOnOp = + ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Idle}, prefetch, null!); + deferOnOps.push(deferOnOp); + } + if (triggers.interaction !== undefined) { + const deferOnOp = ir.createDeferOnOp( + deferXref, { + kind: ir.DeferTriggerKind.Interaction, + targetName: triggers.interaction.reference, + targetXref: null, + targetSlot: null, + targetView: null, + targetSlotViewSteps: null, + }, + prefetch, null!); + deferOnOps.push(deferOnOp); } - unit.create.push(deferOp.placeholder); + prefetch = true; } - - if (error && deferBlock.error) { - deferOp.error = - ir.createDeferSecondaryOp(deferOp.xref, error.xref, ir.DeferSecondaryKind.Error); - unit.create.push(deferOp.error); + if (deferOnOps.length === 0) { + deferOnOps.push(ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Idle}, false, null!)); } - - // Configure all defer conditions. - const deferOnOp = ir.createDeferOnOp(unit.job.allocateXrefId(), null!); - - // Add all ops to the view. - unit.create.push(deferOnOp); + unit.create.push(deferOnOps); } function ingestIcu(unit: ViewCompilationUnit, icu: t.Icu) { @@ -452,7 +463,8 @@ function ingestForBlock(unit: ViewCompilationUnit, forBlock: t.ForLoopBlock): vo const expression = convertAst( forBlock.expression, unit.job, convertSourceSpan(forBlock.expression.span, forBlock.sourceSpan)); - const repeater = ir.createRepeaterOp(repeaterCreate.xref, expression, forBlock.sourceSpan); + const repeater = ir.createRepeaterOp( + repeaterCreate.xref, repeaterCreate.slot, expression, forBlock.sourceSpan); unit.update.push(repeater); } @@ -534,6 +546,7 @@ function convertAst( // TODO: pipes should probably have source maps; figure out details. return new ir.PipeBindingExpr( job.allocateXrefId(), + new ir.SlotHandle(), ast.name, [ convertAst(ast.exp, job, baseSourceSpan), @@ -636,8 +649,8 @@ function ingestBindings( continue; } - listenerOp = - ir.createListenerOp(op.xref, output.name, op.tag, output.phase, false, output.sourceSpan); + listenerOp = ir.createListenerOp( + op.xref, op.slot, output.name, op.tag, output.phase, false, output.sourceSpan); // if output.handler is a chain, then push each statement from the chain separately, and // return the last one? diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 64bb39a949a7b..437fbe7671a76 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -205,8 +205,23 @@ export function defer( return call(Identifiers.defer, args, sourceSpan); } -export function deferOn(sourceSpan: ParseSourceSpan|null): ir.CreateOp { - return call(Identifiers.deferOnIdle, [], sourceSpan); +const deferTriggerToR3TriggerInstructionsMap = new Map([ + [ir.DeferTriggerKind.Idle, [Identifiers.deferOnIdle, Identifiers.deferPrefetchOnIdle]], + [ + ir.DeferTriggerKind.Interaction, + [Identifiers.deferOnInteraction, Identifiers.deferPrefetchOnInteraction] + ], +]); + +export function deferOn( + trigger: ir.DeferTriggerKind, args: number[], prefetch: boolean, + sourceSpan: ParseSourceSpan|null): ir.CreateOp { + const instructions = deferTriggerToR3TriggerInstructionsMap.get(trigger); + if (instructions === undefined) { + throw new Error(`Unable to determine instruction for trigger ${trigger}`); + } + const instructionToCall = prefetch ? instructions[1] : instructions[0]; + return call(instructionToCall, args.map(a => o.literal(a)), sourceSpan); } export function projectionDef(def: o.Expression|null): ir.CreateOp { diff --git a/packages/compiler/src/template/pipeline/src/phases/apply_i18n_expressions.ts b/packages/compiler/src/template/pipeline/src/phases/apply_i18n_expressions.ts index a5d635477e138..818f4bf824549 100644 --- a/packages/compiler/src/template/pipeline/src/phases/apply_i18n_expressions.ts +++ b/packages/compiler/src/template/pipeline/src/phases/apply_i18n_expressions.ts @@ -19,7 +19,7 @@ export function phaseApplyI18nExpressions(job: CompilationJob): void { // Only add apply after expressions that are not followed by more expressions. if (op.kind === ir.OpKind.I18nExpression && needsApplication(op)) { // TODO: what should be the source span for the apply op? - ir.OpList.insertAfter(ir.createI18nApplyOp(op.owner, null!), op); + ir.OpList.insertAfter(ir.createI18nApplyOp(op.owner, op.ownerSlot, null!), op); } } } diff --git a/packages/compiler/src/template/pipeline/src/phases/assign_i18n_slot_dependencies.ts b/packages/compiler/src/template/pipeline/src/phases/assign_i18n_slot_dependencies.ts index aa2de8a039f8f..290f5d5834160 100644 --- a/packages/compiler/src/template/pipeline/src/phases/assign_i18n_slot_dependencies.ts +++ b/packages/compiler/src/template/pipeline/src/phases/assign_i18n_slot_dependencies.ts @@ -14,7 +14,7 @@ import {CompilationJob} from '../compilation'; */ export function phaseAssignI18nSlotDependencies(job: CompilationJob) { const i18nLastSlotConsumers = new Map(); - let lastSlotConsumer = null; + let lastSlotConsumer: ir.XrefId|null = null; for (const unit of job.units) { // Record the last consumed slot before each i18n end instruction. for (const op of unit.create) { diff --git a/packages/compiler/src/template/pipeline/src/phases/conditionals.ts b/packages/compiler/src/template/pipeline/src/phases/conditionals.ts index cde87a99503d7..743da0f84373f 100644 --- a/packages/compiler/src/template/pipeline/src/phases/conditionals.ts +++ b/packages/compiler/src/template/pipeline/src/phases/conditionals.ts @@ -25,8 +25,8 @@ export function phaseConditionals(job: ComponentCompilationJob): void { // Any case with a `null` condition is `default`. If one exists, default to it instead. const defaultCase = op.conditions.findIndex((cond) => cond.expr === null); if (defaultCase >= 0) { - const xref = op.conditions.splice(defaultCase, 1)[0].target; - test = new ir.SlotLiteralExpr(xref); + const slot = op.conditions.splice(defaultCase, 1)[0].targetSlot; + test = new ir.SlotLiteralExpr(slot); } else { // By default, a switch evaluates to `-1`, causing no template to be displayed. test = o.literal(-1); @@ -53,7 +53,7 @@ export function phaseConditionals(job: ComponentCompilationJob): void { op.contextValue = new ir.ReadTemporaryExpr(caseExpressionTemporaryXref); } test = new o.ConditionalExpr( - conditionalCase.expr, new ir.SlotLiteralExpr(conditionalCase.target), test); + conditionalCase.expr, new ir.SlotLiteralExpr(conditionalCase.targetSlot), test); } // Save the resulting aggregate Joost-expression. diff --git a/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts new file mode 100644 index 0000000000000..daea95786bd23 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts @@ -0,0 +1,97 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import type {ViewCompilationUnit, ComponentCompilationJob} from '../compilation'; + +export function phaseDeferResolveTargets(job: ComponentCompilationJob): void { + const scopes = new Map(); + + function getScopeForView(view: ViewCompilationUnit): Scope { + if (scopes.has(view.xref)) { + return scopes.get(view.xref)!; + } + + const scope = new Scope(); + for (const op of view.create) { + // add everything that can be referenced. + if (!ir.isElementOrContainerOp(op) || op.localRefs === null) { + continue; + } + if (!Array.isArray(op.localRefs)) { + throw new Error( + 'LocalRefs were already processed, but were needed to resolve defer targets.'); + } + + for (const ref of op.localRefs) { + if (ref.target !== '') { + continue; + } + scope.targets.set(ref.name, op.xref); + } + } + + scopes.set(view.xref, scope); + return scope; + } + + function resolveTrigger( + deferOwnerView: ViewCompilationUnit, op: ir.DeferOnOp, + placeholderView: ir.XrefId|null): void { + switch (op.trigger.kind) { + case ir.DeferTriggerKind.Idle: + return; + case ir.DeferTriggerKind.Interaction: + if (op.trigger.targetName === null) { + return; + } + let view: ViewCompilationUnit|null = + placeholderView !== null ? job.views.get(placeholderView)! : deferOwnerView; + let step = placeholderView !== null ? -1 : 0; + + while (view !== null) { + const scope = getScopeForView(view); + if (scope.targets.has(op.trigger.targetName)) { + op.trigger.targetXref = scope.targets.get(op.trigger.targetName)!; + op.trigger.targetView = view.xref; + op.trigger.targetSlotViewSteps = step; + return; + } + + view = view.parent !== null ? job.views.get(view.parent)! : null; + step++; + } + break; + default: + throw new Error(`Trigger kind ${(op.trigger as any).kind} not handled`); + } + } + + // Find the defer ops, and assign the data about their targets. + for (const unit of job.units) { + const defers = new Map(); + for (const op of unit.create) { + switch (op.kind) { + case ir.OpKind.Defer: + defers.set(op.xref, op); + break; + case ir.OpKind.DeferOn: + const deferOp = defers.get(op.defer)!; + resolveTrigger(unit, op, deferOp.placeholderView); + break; + } + } + } +} + + + +class Scope { + targets = new Map(); +} diff --git a/packages/compiler/src/template/pipeline/src/phases/generate_advance.ts b/packages/compiler/src/template/pipeline/src/phases/generate_advance.ts index d7bf9d9fa2e21..a35b934385be3 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_advance.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_advance.ts @@ -20,12 +20,12 @@ export function phaseGenerateAdvance(job: CompilationJob): void { for (const op of unit.create) { if (!ir.hasConsumesSlotTrait(op)) { continue; - } else if (op.slot === null) { + } else if (op.slot.slot === null) { throw new Error( `AssertionError: expected slots to have been allocated before generating advance() calls`); } - slotMap.set(op.xref, op.slot); + slotMap.set(op.xref, op.slot.slot); } // Next, step through the update operations and generate `ir.AdvanceOp`s as required to ensure diff --git a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts index 3263036c22d3f..8a45fb59ee698 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts @@ -104,6 +104,8 @@ interface Reference { */ targetId: ir.XrefId; + targetSlot: ir.SlotHandle; + /** * A generated offset of this reference among all the references on a specific element. */ @@ -151,6 +153,7 @@ function getScopeForView(view: ViewCompilationUnit, parent: Scope|null): Scope { scope.references.push({ name: op.localRefs[offset].name, targetId: op.xref, + targetSlot: op.slot, offset, variable: { kind: ir.SemanticVariableKind.Identifier, @@ -205,8 +208,8 @@ function generateVariablesInScopeForView( // Add variables for all local references declared for elements in this scope. for (const ref of scope.references) { newOps.push(ir.createVariableOp( - view.job.allocateXrefId(), ref.variable, new ir.ReferenceExpr(ref.targetId, ref.offset), - ir.VariableFlags.None)); + view.job.allocateXrefId(), ref.variable, + new ir.ReferenceExpr(ref.targetId, ref.targetSlot, ref.offset), ir.VariableFlags.None)); } if (scope.parent !== null) { diff --git a/packages/compiler/src/template/pipeline/src/phases/i18n_text_extraction.ts b/packages/compiler/src/template/pipeline/src/phases/i18n_text_extraction.ts index c3902e41f35fb..f68b0e5b3a8b4 100644 --- a/packages/compiler/src/template/pipeline/src/phases/i18n_text_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/i18n_text_extraction.ts @@ -17,18 +17,21 @@ export function phaseI18nTextExtraction(job: CompilationJob): void { // Remove all text nodes within i18n blocks, their content is already captured in the i18n // message. let currentI18nId: ir.XrefId|null = null; - const textNodes = new Map(); + let currentI18nSlot: ir.SlotHandle|null = null; + const textNodes = new Map(); for (const op of unit.create) { switch (op.kind) { case ir.OpKind.I18nStart: currentI18nId = op.xref; + currentI18nSlot = op.slot; break; case ir.OpKind.I18nEnd: currentI18nId = null; + currentI18nSlot = null; break; case ir.OpKind.Text: - if (currentI18nId !== null) { - textNodes.set(op.xref, currentI18nId); + if (currentI18nId !== null && currentI18nSlot !== null) { + textNodes.set(op.xref, {xref: currentI18nId, slot: currentI18nSlot}); ir.OpList.remove(op); } break; @@ -44,14 +47,14 @@ export function phaseI18nTextExtraction(job: CompilationJob): void { continue; } - const i18nBlockId = textNodes.get(op.target)!; + const i18nBlock = textNodes.get(op.target)!; const ops: ir.UpdateOp[] = []; for (let i = 0; i < op.interpolation.expressions.length; i++) { const expr = op.interpolation.expressions[i]; const placeholder = op.i18nPlaceholders[i]; ops.push(ir.createI18nExpressionOp( - i18nBlockId, expr, placeholder.name, ir.I18nParamResolutionTime.Creation, - expr.sourceSpan ?? op.sourceSpan)); + i18nBlock.xref, i18nBlock.slot, expr, placeholder.name, + ir.I18nParamResolutionTime.Creation, expr.sourceSpan ?? op.sourceSpan)); } if (ops.length > 0) { // ops.push(ir.createI18nApplyOp(i18nBlockId, op.i18nPlaceholders, op.sourceSpan)); diff --git a/packages/compiler/src/template/pipeline/src/phases/icu_extraction.ts b/packages/compiler/src/template/pipeline/src/phases/icu_extraction.ts index 56533bacd4433..f01b23f501e80 100644 --- a/packages/compiler/src/template/pipeline/src/phases/icu_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/icu_extraction.ts @@ -16,21 +16,27 @@ import {CompilationJob} from '../compilation'; export function phaseIcuExtraction(job: CompilationJob): void { for (const unit of job.units) { // Build a map of ICU to the i18n block they belong to, then remove the `Icu` ops. - const icus = new Map(); + const icus = new Map< + ir.XrefId, {message: i18n.Message, i18nBlockId: ir.XrefId, i18nBlockSlot: ir.SlotHandle}>(); let currentI18nId: ir.XrefId|null = null; + let currentI18nSlot: ir.SlotHandle|null = null; for (const op of unit.create) { switch (op.kind) { case ir.OpKind.I18nStart: currentI18nId = op.xref; + currentI18nSlot = op.slot; break; case ir.OpKind.I18nEnd: currentI18nId = null; + currentI18nSlot = null; break; case ir.OpKind.Icu: if (currentI18nId === null) { throw Error('Unexpected ICU outside of an i18n block.'); } - icus.set(op.xref, {message: op.message, i18nBlockId: currentI18nId}); + icus.set( + op.xref, + {message: op.message, i18nBlockId: currentI18nId, i18nBlockSlot: currentI18nSlot!}); ir.OpList.remove(op); break; } @@ -40,7 +46,7 @@ export function phaseIcuExtraction(job: CompilationJob): void { for (const op of unit.update) { switch (op.kind) { case ir.OpKind.IcuUpdate: - const {message, i18nBlockId} = icus.get(op.xref)!; + const {message, i18nBlockId, i18nBlockSlot} = icus.get(op.xref)!; const icuNode = message.nodes.find((n): n is i18n.Icu => n instanceof i18n.Icu); if (icuNode === undefined) { throw Error('Could not find ICU in i18n AST'); @@ -51,7 +57,7 @@ export function phaseIcuExtraction(job: CompilationJob): void { ir.OpList.replace( op, ir.createI18nExpressionOp( - i18nBlockId, new ir.LexicalReadExpr(icuNode.expression), + i18nBlockId, i18nBlockSlot, new ir.LexicalReadExpr(icuNode.expression), icuNode.expressionPlaceholder, ir.I18nParamResolutionTime.Postproccessing, null!)); break; diff --git a/packages/compiler/src/template/pipeline/src/phases/naming.ts b/packages/compiler/src/template/pipeline/src/phases/naming.ts index 75c81afc27090..e78ab96f92819 100644 --- a/packages/compiler/src/template/pipeline/src/phases/naming.ts +++ b/packages/compiler/src/template/pipeline/src/phases/naming.ts @@ -46,7 +46,7 @@ function addNamesToView( if (op.handlerFnName !== null) { break; } - if (!op.hostListener && op.targetSlot === null) { + if (!op.hostListener && op.targetSlot.slot === null) { throw new Error(`Expected a slot to be assigned`); } let animation = ''; @@ -58,7 +58,7 @@ function addNamesToView( op.handlerFnName = `${baseName}_${animation}${op.name}_HostBindingHandler`; } else { op.handlerFnName = `${unit.fnName}_${op.tag!.replace('-', '_')}_${animation}${op.name}_${ - op.targetSlot}_listener`; + op.targetSlot.slot}_listener`; } op.handlerFnName = sanitizeIdentifier(op.handlerFnName); break; @@ -69,7 +69,7 @@ function addNamesToView( if (!(unit instanceof ViewCompilationUnit)) { throw new Error(`AssertionError: must be compiling a component`); } - if (op.slot === null) { + if (op.slot.slot === null) { throw new Error(`Expected slot to be assigned`); } if (op.emptyView !== null) { @@ -77,14 +77,15 @@ function addNamesToView( // Repeater empty view function is at slot +2 (metadata is in the first slot). addNamesToView( emptyView, - `${baseName}_${prefixWithNamespace(`${op.tag}Empty`, op.namespace)}_${op.slot + 2}`, + `${baseName}_${prefixWithNamespace(`${op.tag}Empty`, op.namespace)}_${ + op.slot.slot + 2}`, state, compatibility); } const repeaterToken = op.tag === null ? '' : '_' + prefixWithNamespace(op.tag, op.namespace); // Repeater primary view function is at slot +1 (metadata is in the first slot). addNamesToView( - unit.job.views.get(op.xref)!, `${baseName}${repeaterToken}_${op.slot + 1}`, state, + unit.job.views.get(op.xref)!, `${baseName}${repeaterToken}_${op.slot.slot + 1}`, state, compatibility); break; case ir.OpKind.Template: @@ -92,11 +93,11 @@ function addNamesToView( throw new Error(`AssertionError: must be compiling a component`); } const childView = unit.job.views.get(op.xref)!; - if (op.slot === null) { + if (op.slot.slot === null) { throw new Error(`Expected slot to be assigned`); } const tagToken = op.tag === null ? '' : '_' + prefixWithNamespace(op.tag, op.namespace); - addNamesToView(childView, `${baseName}${tagToken}_${op.slot}`, state, compatibility); + addNamesToView(childView, `${baseName}${tagToken}_${op.slot.slot}`, state, compatibility); break; case ir.OpKind.StyleProp: op.name = normalizeStylePropName(op.name); diff --git a/packages/compiler/src/template/pipeline/src/phases/ordering.ts b/packages/compiler/src/template/pipeline/src/phases/ordering.ts index 99d32c391ecbe..9456b1481ece3 100644 --- a/packages/compiler/src/template/pipeline/src/phases/ordering.ts +++ b/packages/compiler/src/template/pipeline/src/phases/ordering.ts @@ -81,7 +81,7 @@ export function phaseOrdering(job: CompilationJob) { */ function orderWithin( opList: ir.OpList, ordering: Array>) { - let opsToOrder = []; + let opsToOrder: Array = []; // Only reorder ops that target the same xref; do not mix ops that target different xrefs. let firstTargetInGroup: ir.XrefId|null = null; for (const op of opList) { diff --git a/packages/compiler/src/template/pipeline/src/phases/pipe_creation.ts b/packages/compiler/src/template/pipeline/src/phases/pipe_creation.ts index f42440fea4546..0f1eb5c5064ff 100644 --- a/packages/compiler/src/template/pipeline/src/phases/pipe_creation.ts +++ b/packages/compiler/src/template/pipeline/src/phases/pipe_creation.ts @@ -30,22 +30,18 @@ function processPipeBindingsInView(unit: CompilationUnit): void { throw new Error(`AssertionError: pipe bindings should not appear in child expressions`); } - // This update op must be associated with a create op that consumes a slot (either by - // depending on the ambient context of `target`, or merely referencing that create op via - // `target`). - if (!ir.hasDependsOnSlotContextTrait(updateOp) && - !ir.hasUsesSlotIndexTrait(updateOp)) { - throw new Error(`AssertionError: pipe binding associated with non-slot operation ${ - ir.OpKind[updateOp.kind]}`); - } - if (unit.job.compatibility) { - addPipeToCreationBlock(unit, updateOp.target, expr); + // TODO: We can delete this cast and check once compatibility mode is removed. + const slotHandle = (updateOp as any).target; + if (slotHandle == undefined) { + throw new Error(`AssertionError: expected slot handle to be assigned for pipe creation`); + } + addPipeToCreationBlock(unit, (updateOp as any).target, expr); } else { // When not in compatibility mode, we just add the pipe to the end of the create block. This // is not only simpler and faster, but allows more chaining opportunities for other // instructions. - unit.create.push(ir.createPipeOp(expr.target, expr.name)); + unit.create.push(ir.createPipeOp(expr.target, expr.targetSlot, expr.name)); } }); } @@ -71,7 +67,7 @@ function addPipeToCreationBlock( op = op.next!; } - const pipe = ir.createPipeOp(binding.target, binding.name) as ir.CreateOp; + const pipe = ir.createPipeOp(binding.target, binding.targetSlot, binding.name) as ir.CreateOp; ir.OpList.insertBefore(pipe, op.next!); // This completes adding the pipe to the creation block. diff --git a/packages/compiler/src/template/pipeline/src/phases/pipe_variadic.ts b/packages/compiler/src/template/pipeline/src/phases/pipe_variadic.ts index 2da064a4aefff..839dbb4617851 100644 --- a/packages/compiler/src/template/pipeline/src/phases/pipe_variadic.ts +++ b/packages/compiler/src/template/pipeline/src/phases/pipe_variadic.ts @@ -25,7 +25,7 @@ export function phasePipeVariadic(job: CompilationJob): void { } return new ir.PipeBindingVariadicExpr( - expr.target, expr.name, o.literalArr(expr.args), expr.args.length); + expr.target, expr.targetSlot, expr.name, o.literalArr(expr.args), expr.args.length); }, ir.VisitorContextFlag.None); } } diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 6c027c07f540a..2a4f583bb80ca 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -44,21 +44,21 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList(op); + op.slot.slot!, op.mainSlot.slot!, null, op.loadingSlot?.slot ?? null, + op.placeholderSlot?.slot! ?? null, op.errorSlot?.slot ?? null, + /* TODO: const indices */ null, null, op.sourceSpan)); break; case ir.OpKind.DeferOn: - ir.OpList.replace(op, ng.deferOn(op.sourceSpan)); + let args: number[] = []; + switch (op.trigger.kind) { + case ir.DeferTriggerKind.Idle: + break; + case ir.DeferTriggerKind.Interaction: + if (op.trigger.targetSlot === null || op.trigger.targetSlotViewSteps === null) { + throw new Error('Slot or view steps not set in trigger reification'); + } + args = [op.trigger.targetSlot]; + if (op.trigger.targetSlotViewSteps !== 0) { + args.push(op.trigger.targetSlotViewSteps); + } + break; + default: + throw new Error(`AssertionError: Unsupported reification of defer trigger kind ${ + (op.trigger as any).kind}`); + } + ir.OpList.replace(op, ng.deferOn(op.trigger.kind, args, op.prefetch, op.sourceSpan)); break; case ir.OpKind.ProjectionDef: ir.OpList.replace(op, ng.projectionDef(op.def)); break; case ir.OpKind.Projection: - if (op.slot === null) { + if (op.slot.slot === null) { throw new Error('No slot was assigned for project instruction'); } ir.OpList.replace( - op, ng.projection(op.slot, op.projectionSlotIndex, op.attributes, op.sourceSpan)); + op, ng.projection(op.slot.slot!, op.projectionSlotIndex, op.attributes, op.sourceSpan)); break; case ir.OpKind.RepeaterCreate: - if (op.slot === null) { + if (op.slot.slot === null) { throw new Error('No slot was assigned for repeater instruction'); } if (!(unit instanceof ViewCompilationUnit)) { @@ -197,7 +211,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList { - if (!ir.isIrExpression(expr)) { - return; - } - - if (!ir.hasUsesSlotIndexTrait(expr) || expr.targetSlot !== null) { - return; - } - - // The `UsesSlotIndexExprTrait` indicates that this expression references something declared - // in this component template by its slot index. Use the `target` `ir.XrefId` to find the - // allocated slot for that declaration in `slotMap`. - - if (!slotMap.has(expr.target)) { - // We do expect to find a slot allocated for everything which might be referenced. - throw new Error(`AssertionError: no slot allocated for ${expr.constructor.name} target ${ - expr.target}`); - } - - // Record the allocated slot on the expression. - expr.targetSlot = slotMap.get(expr.target)!; - }); } } } From 79684499e465618641efc444a62aec92b890b356 Mon Sep 17 00:00:00 2001 From: Dylan Hunn Date: Wed, 25 Oct 2023 16:29:53 -0700 Subject: [PATCH 21/52] refactor(compiler): Introduce `ConstCollectedExpr`, and use it in defer on expressions (#52387) Previously, we supported a `HasConst` trait, allowing an op to be const collected automatically. However, that approach had the shortcoming that each op could only collect a single constant. Instead, we now provide a `ConstCollectedExpr`, which collects constants at the expression level, allowing ops to have multiple collectible consts. Then, we use this new abstraction to support the `defer on` conditions. PR Close #52387 --- .../r3_view_compiler_deferred/TEST_CASES.json | 15 ++---- .../src/template/pipeline/ir/src/enums.ts | 5 ++ .../template/pipeline/ir/src/expression.ts | 44 ++++++++++++++++- .../template/pipeline/ir/src/ops/create.ts | 20 ++++++-- .../src/template/pipeline/ir/src/traits.ts | 47 ------------------- .../src/template/pipeline/src/conversion.ts | 6 ++- .../src/template/pipeline/src/emit.ts | 8 +++- .../src/template/pipeline/src/ingest.ts | 11 +++-- .../src/template/pipeline/src/instruction.ts | 14 +++--- .../pipeline/src/phases/defer_configs.ts | 31 ++++++++++++ .../src/phases/defer_resolve_targets.ts | 11 +++-- .../phases/has_const_expression_collection.ts | 24 ++++++++++ .../src/phases/has_const_trait_collection.ts | 38 --------------- .../src/template/pipeline/src/phases/reify.ts | 8 ++-- .../src/phases/variable_optimization.ts | 6 +-- 15 files changed, 162 insertions(+), 126 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/phases/defer_configs.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/has_const_expression_collection.ts delete mode 100644 packages/compiler/src/template/pipeline/src/phases/has_const_trait_collection.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json index e2066f6603445..d4269aa3b64fb 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json @@ -16,8 +16,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with secondary blocks", @@ -34,8 +33,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with placeholder block parameters", @@ -181,8 +179,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with an interaction trigger in a parent view", @@ -199,8 +196,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with an interaction trigger inside the placeholder", @@ -217,8 +213,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with implicit trigger references", diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 4608ea11b49b3..fec094087fdab 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -355,6 +355,11 @@ export enum ExpressionKind { * properties ($even, $first, etc.). */ DerivedRepeaterVar, + + /** + * An expression that will be automatically extracted to the component const array. + */ + ConstCollected, } export enum VariableFlags { diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 99a8c64f2aaba..6b750b87c9936 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -24,7 +24,7 @@ export type Expression = LexicalReadExpr|ReferenceExpr|ContextExpr|NextContextEx GetCurrentViewExpr|RestoreViewExpr|ResetViewExpr|ReadVariableExpr|PureFunctionExpr| PureFunctionParameterExpr|PipeBindingExpr|PipeBindingVariadicExpr|SafePropertyReadExpr| SafeKeyedReadExpr|SafeInvokeFunctionExpr|EmptyExpr|AssignTemporaryExpr|ReadTemporaryExpr| - SanitizerExpr|SlotLiteralExpr|ConditionalCaseExpr|DerivedRepeaterVarExpr; + SanitizerExpr|SlotLiteralExpr|ConditionalCaseExpr|DerivedRepeaterVarExpr|ConstCollectedExpr; /** * Transformer type which converts expressions into general `o.Expression`s (which may be an @@ -853,6 +853,38 @@ export class DerivedRepeaterVarExpr extends ExpressionBase { } } +export class ConstCollectedExpr extends ExpressionBase { + override readonly kind = ExpressionKind.ConstCollected; + + constructor(public expr: o.Expression) { + super(); + } + + override transformInternalExpressions(transform: ExpressionTransform, flags: VisitorContextFlag): + void { + this.expr = transform(this.expr, flags); + } + + override visitExpression(visitor: o.ExpressionVisitor, context: any) { + this.expr.visitExpression(visitor, context); + } + + override isEquivalent(e: o.Expression): boolean { + if (!(e instanceof ConstCollectedExpr)) { + return false; + } + return this.expr.isEquivalent(e.expr); + } + + override isConstant(): boolean { + return this.expr.isConstant(); + } + + override clone(): ConstCollectedExpr { + return new ConstCollectedExpr(this.expr); + } +} + /** * Visits all `Expression`s in the AST of `op` with the `visitor` function. */ @@ -953,11 +985,19 @@ export function transformExpressionsInOp( case OpKind.Repeater: op.collection = transformExpressionsInExpression(op.collection, transform, flags); break; + case OpKind.Defer: + if (op.loadingConfig !== null) { + op.loadingConfig = transformExpressionsInExpression(op.loadingConfig, transform, flags); + } + if (op.placeholderConfig !== null) { + op.placeholderConfig = + transformExpressionsInExpression(op.placeholderConfig, transform, flags); + } + break; case OpKind.Advance: case OpKind.Container: case OpKind.ContainerEnd: case OpKind.ContainerStart: - case OpKind.Defer: case OpKind.DeferOn: case OpKind.DisableBindings: case OpKind.Element: diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 519de569ebba4..1c4b4c4c821b2 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -679,12 +679,18 @@ export interface DeferOp extends Op, ConsumesSlotOpTrait { errorSlot: SlotHandle|null; + placeholderMinimumTime: number|null; + loadingMinimumTime: number|null; + loadingAfterTime: number|null; + + placeholderConfig: o.Expression|null; + loadingConfig: o.Expression|null; + sourceSpan: ParseSourceSpan; } export function createDeferOp( - xref: XrefId, main: XrefId, mainSlot: SlotHandle, placeholderView: XrefId|null, - placeholderSlot: SlotHandle|null, sourceSpan: ParseSourceSpan): DeferOp { + xref: XrefId, main: XrefId, mainSlot: SlotHandle, sourceSpan: ParseSourceSpan): DeferOp { return { kind: OpKind.Defer, xref, @@ -693,13 +699,19 @@ export function createDeferOp( mainSlot, loadingView: null, loadingSlot: null, + loadingConfig: null, + loadingMinimumTime: null, + loadingAfterTime: null, placeholderView: null, - placeholderSlot, + placeholderSlot: null, + placeholderConfig: null, + placeholderMinimumTime: null, errorView: null, errorSlot: null, sourceSpan, ...NEW_OP, ...TRAIT_CONSUMES_SLOT, + numSlotsUsed: 2, }; } interface DeferTriggerBase { @@ -724,7 +736,7 @@ interface DeferInteractionTrigger extends DeferTriggerBase { * The slot index of the named reference, inside the view provided below. This slot may not be * inside the current view, and is handled specially as a result. */ - targetSlot: number|null; + targetSlot: SlotHandle|null; targetView: XrefId|null; diff --git a/packages/compiler/src/template/pipeline/ir/src/traits.ts b/packages/compiler/src/template/pipeline/ir/src/traits.ts index 3e594d40291da..196abf7007914 100644 --- a/packages/compiler/src/template/pipeline/ir/src/traits.ts +++ b/packages/compiler/src/template/pipeline/ir/src/traits.ts @@ -32,11 +32,6 @@ export const ConsumesVarsTrait = Symbol('ConsumesVars'); */ export const UsesVarOffset = Symbol('UsesVarOffset'); -/** - * Marker symbol for `HasConst` trait. - */ -export const HasConst = Symbol('HasConst'); - /** * Marks an operation as requiring allocation of one or more data slots for storage. */ @@ -102,30 +97,6 @@ export interface UsesVarOffsetTrait { varOffset: number|null; } -/** - * Marker trait indicating that an op or expression has some data which should be collected into the - * component constant array. - * - */ -export interface HasConstTrait { - [HasConst]: true; - - /** - * The constant to be collected into the const array, if non-null. - */ - constValue: unknown|null; - - /** - * The index of the collected constant, after processing. - */ - constIndex: number|null; - - /** - * A callback which converts the constValue into an o.Expression for the const array. - */ - makeExpression: (value: unknown) => o.Expression; -} - /** * Default values for most `ConsumesSlotOpTrait` fields (used with the spread operator to initialize * implementors of the trait). @@ -161,15 +132,6 @@ export const TRAIT_USES_VAR_OFFSET: UsesVarOffsetTrait = { varOffset: null, } as const; -/** - * Default values for `HasConst` fields (used with the spread operator to initialize - * implementors of this trait). - */ -export const TRAIT_HAS_CONST: Omit = { - [HasConst]: true, - constIndex: null, -} as const; - /** * Test whether an operation implements `ConsumesSlotOpTrait`. */ @@ -202,12 +164,3 @@ export function hasUsesVarOffsetTrait(expr: ExprT): ex UsesVarOffsetTrait { return (expr as Partial)[UsesVarOffset] === true; } - -/** - * Test whether an operation or expression implements `HasConstTrait`. - */ -export function hasConstTrait(expr: ExprT): expr is ExprT&HasConstTrait; -export function hasConstTrait>(op: OpT): op is OpT&HasConstTrait; -export function hasConstTrait(value: any): boolean { - return (value as Partial)[HasConst] === true; -} diff --git a/packages/compiler/src/template/pipeline/src/conversion.ts b/packages/compiler/src/template/pipeline/src/conversion.ts index 6a595288f3655..d4a24f9a0b5d1 100644 --- a/packages/compiler/src/template/pipeline/src/conversion.ts +++ b/packages/compiler/src/template/pipeline/src/conversion.ts @@ -54,9 +54,11 @@ export function prefixWithNamespace(strippedTag: string, namespace: ir.Namespace return `:${keyForNamespace(namespace)}:${strippedTag}`; } -export function literalOrArrayLiteral(value: any): o.Expression { +export type LiteralType = string|number|boolean|null|Array; + +export function literalOrArrayLiteral(value: LiteralType): o.Expression { if (Array.isArray(value)) { return o.literalArr(value.map(literalOrArrayLiteral)); } - return o.literal(value, o.INFERRED_TYPE); + return o.literal(value); } diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index a7697062df55c..943b78ca35c53 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -28,7 +28,7 @@ import {phaseFormatI18nParams} from './phases/format_i18n_params'; import {phaseGenerateAdvance} from './phases/generate_advance'; import {phaseGenerateProjectionDef} from './phases/generate_projection_def'; import {phaseGenerateVariables} from './phases/generate_variables'; -import {phaseConstTraitCollection} from './phases/has_const_trait_collection'; +import {phaseConstExpressionCollection} from './phases/has_const_expression_collection'; import {phaseHostStylePropertyParsing} from './phases/host_style_property_parsing'; import {phaseI18nConstCollection} from './phases/i18n_const_collection'; import {phaseI18nMessageExtraction} from './phases/i18n_message_extraction'; @@ -69,6 +69,8 @@ import {phaseTrackVariables} from './phases/track_variables'; import {phaseVarCounting} from './phases/var_counting'; import {phaseVariableOptimization} from './phases/variable_optimization'; import {phaseWrapIcus} from './phases/wrap_icus'; +import {phaseDeferResolveTargets} from './phases/defer_resolve_targets'; +import {phaseDeferConfigs} from './phases/defer_configs'; type Phase = { fn: (job: CompilationJob) => void; kind: Kind.Both | Kind.Host | Kind.Tmpl; @@ -96,6 +98,7 @@ const phases: Phase[] = [ {kind: Kind.Both, fn: phaseOrdering}, {kind: Kind.Tmpl, fn: phaseConditionals}, {kind: Kind.Tmpl, fn: phasePipeCreation}, + {kind: Kind.Tmpl, fn: phaseDeferConfigs}, {kind: Kind.Tmpl, fn: phaseI18nTextExtraction}, {kind: Kind.Tmpl, fn: phaseIcuExtraction}, {kind: Kind.Tmpl, fn: phaseApplyI18nExpressions}, @@ -109,6 +112,7 @@ const phases: Phase[] = [ {kind: Kind.Tmpl, fn: phaseRepeaterDerivedVars}, {kind: Kind.Tmpl, fn: phaseTrackVariables}, {kind: Kind.Both, fn: phaseResolveNames}, + {kind: Kind.Tmpl, fn: phaseDeferResolveTargets}, {kind: Kind.Tmpl, fn: phaseTrackFnOptimization}, {kind: Kind.Both, fn: phaseResolveContexts}, {kind: Kind.Tmpl, fn: phaseResolveSanitizers}, // TODO: run in both @@ -124,7 +128,7 @@ const phases: Phase[] = [ {kind: Kind.Tmpl, fn: phaseFormatI18nParams}, {kind: Kind.Tmpl, fn: phaseTrackFnGeneration}, {kind: Kind.Tmpl, fn: phaseI18nConstCollection}, - {kind: Kind.Tmpl, fn: phaseConstTraitCollection}, + {kind: Kind.Tmpl, fn: phaseConstExpressionCollection}, {kind: Kind.Both, fn: phaseConstCollection}, {kind: Kind.Tmpl, fn: phaseAssignI18nSlotDependencies}, {kind: Kind.Both, fn: phaseVarCounting}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 58fd47b403957..d2a4b6d25f673 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -363,9 +363,14 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock // Create the main defer op, and ops for all secondary views. const deferXref = unit.job.allocateXrefId(); - const deferOp = ir.createDeferOp( - deferXref, main.xref, main.slot, placeholder?.xref ?? null, placeholder?.slot ?? null, - deferBlock.sourceSpan); + const deferOp = ir.createDeferOp(deferXref, main.xref, main.slot, deferBlock.sourceSpan); + deferOp.placeholderView = placeholder?.xref ?? null; + deferOp.placeholderSlot = placeholder?.slot ?? null; + deferOp.loadingSlot = loading?.slot ?? null; + deferOp.errorSlot = error?.slot ?? null; + deferOp.placeholderMinimumTime = deferBlock.placeholder?.minimumTime ?? null; + deferOp.loadingMinimumTime = deferBlock.loading?.minimumTime ?? null; + deferOp.loadingAfterTime = deferBlock.loading?.afterTime ?? null; unit.create.push(deferOp); // Configure all defer `on` conditions. diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 437fbe7671a76..700e124fe7cf8 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -185,20 +185,22 @@ export function text( export function defer( selfSlot: number, primarySlot: number, dependencyResolverFn: null, loadingSlot: number|null, - placeholderSlot: number|null, errorSlot: number|null, loadingConfigIndex: number|null, - placeholderConfigIndex: number|null, sourceSpan: ParseSourceSpan|null): ir.CreateOp { - const args = [ + placeholderSlot: number|null, errorSlot: number|null, loadingConfig: o.Expression|null, + placeholderConfig: o.Expression|null, sourceSpan: ParseSourceSpan|null): ir.CreateOp { + const args: Array = [ o.literal(selfSlot), o.literal(primarySlot), o.literal(dependencyResolverFn), o.literal(loadingSlot), o.literal(placeholderSlot), o.literal(errorSlot), - o.literal(loadingConfigIndex), - o.literal(placeholderConfigIndex), + loadingConfig ?? o.literal(null), + placeholderConfig ?? o.literal(null), ]; - while (args[args.length - 1].value === null) { + let expr: o.Expression; + while ((expr = args[args.length - 1]) !== null && expr instanceof o.LiteralExpr && + expr.value === null) { args.pop(); } diff --git a/packages/compiler/src/template/pipeline/src/phases/defer_configs.ts b/packages/compiler/src/template/pipeline/src/phases/defer_configs.ts new file mode 100644 index 0000000000000..876c27b9f4442 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/defer_configs.ts @@ -0,0 +1,31 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import type {ViewCompilationUnit, ComponentCompilationJob} from '../compilation'; +import {literalOrArrayLiteral} from '../conversion'; + +export function phaseDeferConfigs(job: ComponentCompilationJob): void { + for (const unit of job.units) { + for (const op of unit.create) { + if (op.kind !== ir.OpKind.Defer) { + continue; + } + + if (op.placeholderMinimumTime !== null) { + op.placeholderConfig = + new ir.ConstCollectedExpr(literalOrArrayLiteral([op.placeholderMinimumTime])); + } + if (op.loadingMinimumTime !== null || op.loadingAfterTime !== null) { + op.loadingConfig = new ir.ConstCollectedExpr( + literalOrArrayLiteral([op.loadingMinimumTime, op.loadingAfterTime])); + } + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts index daea95786bd23..2f86aa7acd019 100644 --- a/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts +++ b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts @@ -33,7 +33,7 @@ export function phaseDeferResolveTargets(job: ComponentCompilationJob): void { if (ref.target !== '') { continue; } - scope.targets.set(ref.name, op.xref); + scope.targets.set(ref.name, {xref: op.xref, slot: op.slot}); } } @@ -58,9 +58,12 @@ export function phaseDeferResolveTargets(job: ComponentCompilationJob): void { while (view !== null) { const scope = getScopeForView(view); if (scope.targets.has(op.trigger.targetName)) { - op.trigger.targetXref = scope.targets.get(op.trigger.targetName)!; + const {xref, slot} = scope.targets.get(op.trigger.targetName)!; + + op.trigger.targetXref = xref; op.trigger.targetView = view.xref; op.trigger.targetSlotViewSteps = step; + op.trigger.targetSlot = slot; return; } @@ -90,8 +93,6 @@ export function phaseDeferResolveTargets(job: ComponentCompilationJob): void { } } - - class Scope { - targets = new Map(); + targets = new Map(); } diff --git a/packages/compiler/src/template/pipeline/src/phases/has_const_expression_collection.ts b/packages/compiler/src/template/pipeline/src/phases/has_const_expression_collection.ts new file mode 100644 index 0000000000000..58b581c36fbd9 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/has_const_expression_collection.ts @@ -0,0 +1,24 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import type {ComponentCompilationJob} from '../compilation'; + +export function phaseConstExpressionCollection(job: ComponentCompilationJob): void { + for (const unit of job.units) { + for (const op of unit.ops()) { + ir.transformExpressionsInOp(op, expr => { + if (!(expr instanceof ir.ConstCollectedExpr)) { + return expr; + } + return o.literal(job.addConst(expr.expr)); + }, ir.VisitorContextFlag.None); + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/has_const_trait_collection.ts b/packages/compiler/src/template/pipeline/src/phases/has_const_trait_collection.ts deleted file mode 100644 index bb8330f80270b..0000000000000 --- a/packages/compiler/src/template/pipeline/src/phases/has_const_trait_collection.ts +++ /dev/null @@ -1,38 +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 * as o from '../../../../output/output_ast'; -import * as ir from '../../ir'; -import type {ComponentCompilationJob} from '../compilation'; - -/** - * Looks for the HasConst trait, indicating that an op or expression has some data which - * should be collected into the constant array. Capable of collecting either a single literal value, - * or an array literal. - */ -export function phaseConstTraitCollection(job: ComponentCompilationJob): void { - const collectGlobalConsts = (e: o.Expression): o.Expression => { - if (e instanceof ir.ExpressionBase && ir.hasConstTrait(e as ir.Expression)) { - // TODO: Figure out how to make this type narrowing work. - const ea = e as unknown as ir.ExpressionBase & ir.HasConstTrait; - if (ea.constValue !== null) { - ea.constIndex = job.addConst(ea.constValue as unknown as o.Expression); - } - } - return e; - }; - - for (const unit of job.units) { - for (const op of unit.ops()) { - if (ir.hasConstTrait(op) && op.constValue !== null) { - op.constIndex = job.addConst(op.makeExpression(op.constValue)); - } - ir.transformExpressionsInOp(op, collectGlobalConsts, ir.VisitorContextFlag.None); - } - } -} diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 2a4f583bb80ca..d57b5927d6264 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -145,8 +145,8 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList Date: Mon, 30 Oct 2023 13:35:06 -0700 Subject: [PATCH 22/52] refactor(compiler): Implement the remaining `defer on` triggers (#52387) The previous commits provided the scaffolding for `defer on`. In this commit, we build on that work, adding triggers for `immediate`, `timer`, `hover`, and `viewport`. PR Close #52387 --- .../r3_view_compiler_deferred/TEST_CASES.json | 3 +- .../src/template/pipeline/ir/src/enums.ts | 4 ++ .../template/pipeline/ir/src/ops/create.ts | 37 ++++++++++---- .../src/template/pipeline/src/ingest.ts | 48 +++++++++++++++++-- .../src/template/pipeline/src/instruction.ts | 13 ++++- .../src/phases/defer_resolve_targets.ts | 4 ++ .../src/template/pipeline/src/phases/reify.ts | 13 ++++- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json index d4269aa3b64fb..0d8003adb852a 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json @@ -68,8 +68,7 @@ ], "failureMessage": "Incorrect template" } - ], - "skipForTemplatePipeline": true + ] }, { "description": "should generate a deferred block with local dependencies", diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index fec094087fdab..a4ab9e1d79b84 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -527,5 +527,9 @@ export enum Namespace { */ export enum DeferTriggerKind { Idle, + Immediate, + Timer, + Hover, Interaction, + Viewport, } diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 1c4b4c4c821b2..0b114f1afa5da 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -718,13 +718,7 @@ interface DeferTriggerBase { kind: DeferTriggerKind; } -interface DeferIdleTrigger extends DeferTriggerBase { - kind: DeferTriggerKind.Idle; -} - -interface DeferInteractionTrigger extends DeferTriggerBase { - kind: DeferTriggerKind.Interaction; - +interface DeferTriggerWithTargetBase extends DeferTriggerBase { targetName: string|null; /** @@ -746,10 +740,37 @@ interface DeferInteractionTrigger extends DeferTriggerBase { targetSlotViewSteps: number|null; } +interface DeferIdleTrigger extends DeferTriggerBase { + kind: DeferTriggerKind.Idle; +} + +interface DeferImmediateTrigger extends DeferTriggerBase { + kind: DeferTriggerKind.Immediate; +} + +interface DeferHoverTrigger extends DeferTriggerWithTargetBase { + kind: DeferTriggerKind.Hover; +} + +interface DeferTimerTrigger extends DeferTriggerBase { + kind: DeferTriggerKind.Timer; + + delay: number; +} + +interface DeferInteractionTrigger extends DeferTriggerWithTargetBase { + kind: DeferTriggerKind.Interaction; +} + +interface DeferViewportTrigger extends DeferTriggerWithTargetBase { + kind: DeferTriggerKind.Viewport; +} + /** * The union type of all defer trigger interfaces. */ -export type DeferTrigger = DeferIdleTrigger|DeferInteractionTrigger; +export type DeferTrigger = DeferIdleTrigger|DeferImmediateTrigger|DeferTimerTrigger| + DeferHoverTrigger|DeferInteractionTrigger|DeferViewportTrigger; export interface DeferOnOp extends Op { kind: OpKind.DeferOn; diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index d2a4b6d25f673..ad213cbc1d8df 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -375,6 +375,8 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock // Configure all defer `on` conditions. + // TODO: refactor prefetch triggers to use a separate op type, with a shared superclass. This will + // make it easier to refactor prefetch behavior in the future. let prefetch = false; let deferOnOps: ir.DeferOnOp[] = []; for (const triggers of [deferBlock.triggers, deferBlock.prefetchTriggers]) { @@ -383,6 +385,30 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Idle}, prefetch, null!); deferOnOps.push(deferOnOp); } + if (triggers.immediate !== undefined) { + const deferOnOp = + ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Immediate}, prefetch, null!); + deferOnOps.push(deferOnOp); + } + if (triggers.timer !== undefined) { + const deferOnOp = ir.createDeferOnOp( + deferXref, {kind: ir.DeferTriggerKind.Timer, delay: triggers.timer.delay}, prefetch, null! + ); + deferOnOps.push(deferOnOp); + } + if (triggers.hover !== undefined) { + const deferOnOp = ir.createDeferOnOp( + deferXref, { + kind: ir.DeferTriggerKind.Hover, + targetName: triggers.hover.reference, + targetXref: null, + targetSlot: null, + targetView: null, + targetSlotViewSteps: null, + }, + prefetch, null!); + deferOnOps.push(deferOnOp); + } if (triggers.interaction !== undefined) { const deferOnOp = ir.createDeferOnOp( deferXref, { @@ -396,11 +422,27 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock prefetch, null!); deferOnOps.push(deferOnOp); } + if (triggers.viewport !== undefined) { + const deferOnOp = ir.createDeferOnOp( + deferXref, { + kind: ir.DeferTriggerKind.Viewport, + targetName: triggers.viewport.reference, + targetXref: null, + targetSlot: null, + targetView: null, + targetSlotViewSteps: null, + }, + prefetch, null!); + deferOnOps.push(deferOnOp); + } + // If no (non-prefetching) defer triggers were provided, default to `idle`. + if (deferOnOps.length === 0) { + deferOnOps.push( + ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Idle}, false, null!)); + } prefetch = true; } - if (deferOnOps.length === 0) { - deferOnOps.push(ir.createDeferOnOp(deferXref, {kind: ir.DeferTriggerKind.Idle}, false, null!)); - } + unit.create.push(deferOnOps); } diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 700e124fe7cf8..3f6f37abb1d44 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -186,7 +186,8 @@ export function text( export function defer( selfSlot: number, primarySlot: number, dependencyResolverFn: null, loadingSlot: number|null, placeholderSlot: number|null, errorSlot: number|null, loadingConfig: o.Expression|null, - placeholderConfig: o.Expression|null, sourceSpan: ParseSourceSpan|null): ir.CreateOp { + placeholderConfig: o.Expression|null, enableTimerScheduling: boolean, + sourceSpan: ParseSourceSpan|null): ir.CreateOp { const args: Array = [ o.literal(selfSlot), o.literal(primarySlot), @@ -196,6 +197,7 @@ export function defer( o.literal(errorSlot), loadingConfig ?? o.literal(null), placeholderConfig ?? o.literal(null), + enableTimerScheduling ? o.importExpr(Identifiers.deferEnableTimerScheduling) : o.literal(null), ]; let expr: o.Expression; @@ -209,10 +211,19 @@ export function defer( const deferTriggerToR3TriggerInstructionsMap = new Map([ [ir.DeferTriggerKind.Idle, [Identifiers.deferOnIdle, Identifiers.deferPrefetchOnIdle]], + [ + ir.DeferTriggerKind.Immediate, + [Identifiers.deferOnImmediate, Identifiers.deferPrefetchOnImmediate] + ], + [ir.DeferTriggerKind.Timer, [Identifiers.deferOnTimer, Identifiers.deferPrefetchOnTimer]], + [ir.DeferTriggerKind.Hover, [Identifiers.deferOnHover, Identifiers.deferPrefetchOnHover]], [ ir.DeferTriggerKind.Interaction, [Identifiers.deferOnInteraction, Identifiers.deferPrefetchOnInteraction] ], + [ + ir.DeferTriggerKind.Viewport, [Identifiers.deferOnViewport, Identifiers.deferPrefetchOnViewport] + ], ]); export function deferOn( diff --git a/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts index 2f86aa7acd019..97ce5246b757a 100644 --- a/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts +++ b/packages/compiler/src/template/pipeline/src/phases/defer_resolve_targets.ts @@ -46,8 +46,12 @@ export function phaseDeferResolveTargets(job: ComponentCompilationJob): void { placeholderView: ir.XrefId|null): void { switch (op.trigger.kind) { case ir.DeferTriggerKind.Idle: + case ir.DeferTriggerKind.Immediate: + case ir.DeferTriggerKind.Timer: return; + case ir.DeferTriggerKind.Hover: case ir.DeferTriggerKind.Interaction: + case ir.DeferTriggerKind.Viewport: if (op.trigger.targetName === null) { return; } diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index d57b5927d6264..fc62fba45311d 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -141,21 +141,30 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList Date: Fri, 20 Oct 2023 13:27:17 -0700 Subject: [PATCH 23/52] refactor(language-service): Add quick info for built in control flow/blocks (#52386) Adds hover info for: * Defer blocks * Triggers and trigger behavior keywords * For loop empty block * Track keyword in for loop block resolves https://github.com/angular/vscode-ng-language-service/issues/1946 PR Close #52386 --- packages/compiler/src/render3/r3_ast.ts | 127 ++++++++---- .../compiler/src/render3/r3_control_flow.ts | 30 +-- .../src/render3/r3_deferred_blocks.ts | 20 +- .../src/render3/r3_deferred_triggers.ts | 118 +++++++++--- packages/compiler/src/render3/view/t2_api.ts | 2 +- .../test/render3/r3_ast_spans_spec.ts | 4 +- .../language-service/src/display_parts.ts | 2 + .../language-service/src/language_service.ts | 3 +- .../language-service/src/outlining_spans.ts | 12 +- packages/language-service/src/quick_info.ts | 76 ++------ .../src/quick_info_built_ins.ts | 182 ++++++++++++++++++ packages/language-service/src/utils.ts | 26 ++- .../language-service/test/quick_info_spec.ts | 142 ++++++++++++++ 13 files changed, 577 insertions(+), 167 deletions(-) create mode 100644 packages/language-service/src/quick_info_built_ins.ts diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 6fe2c2346dd5b..ff92022f0a80c 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -118,7 +118,9 @@ export class Element implements Node { } export abstract class DeferredTrigger implements Node { - constructor(public sourceSpan: ParseSourceSpan) {} + constructor( + public nameSpan: ParseSourceSpan|null, public sourceSpan: ParseSourceSpan, + public prefetchSpan: ParseSourceSpan|null, public whenOrOnSourceSpan: ParseSourceSpan|null) {} visit(visitor: Visitor): Result { return visitor.visitDeferredTrigger(this); @@ -126,8 +128,12 @@ export abstract class DeferredTrigger implements Node { } export class BoundDeferredTrigger extends DeferredTrigger { - constructor(public value: AST, sourceSpan: ParseSourceSpan) { - super(sourceSpan); + constructor( + public value: AST, sourceSpan: ParseSourceSpan, prefetchSpan: ParseSourceSpan|null, + whenSourceSpan: ParseSourceSpan) { + // BoundDeferredTrigger is for 'when' triggers. These aren't really "triggers" and don't have a + // nameSpan. Trigger names are the built in event triggers like hover, interaction, etc. + super(/** nameSpan */ null, sourceSpan, prefetchSpan, whenSourceSpan); } } @@ -136,54 +142,75 @@ export class IdleDeferredTrigger extends DeferredTrigger {} export class ImmediateDeferredTrigger extends DeferredTrigger {} export class HoverDeferredTrigger extends DeferredTrigger { - constructor(public reference: string|null, sourceSpan: ParseSourceSpan) { - super(sourceSpan); + constructor( + public reference: string|null, nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } } export class TimerDeferredTrigger extends DeferredTrigger { - constructor(public delay: number, sourceSpan: ParseSourceSpan) { - super(sourceSpan); + constructor( + public delay: number, nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } } export class InteractionDeferredTrigger extends DeferredTrigger { - constructor(public reference: string|null, sourceSpan: ParseSourceSpan) { - super(sourceSpan); + constructor( + public reference: string|null, nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } } export class ViewportDeferredTrigger extends DeferredTrigger { - constructor(public reference: string|null, sourceSpan: ParseSourceSpan) { - super(sourceSpan); + constructor( + public reference: string|null, nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } } -export class DeferredBlockPlaceholder implements Node { +export class BlockNode { constructor( - public children: Node[], public minimumTime: number|null, public sourceSpan: ParseSourceSpan, + public nameSpan: ParseSourceSpan, public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} +} + +export class DeferredBlockPlaceholder extends BlockNode implements Node { + constructor( + public children: Node[], public minimumTime: number|null, nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitDeferredBlockPlaceholder(this); } } -export class DeferredBlockLoading implements Node { +export class DeferredBlockLoading extends BlockNode implements Node { constructor( public children: Node[], public afterTime: number|null, public minimumTime: number|null, - public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, - public endSourceSpan: ParseSourceSpan|null) {} + nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitDeferredBlockLoading(this); } } -export class DeferredBlockError implements Node { +export class DeferredBlockError extends BlockNode implements Node { constructor( - public children: Node[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} + public children: Node[], nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitDeferredBlockError(this); @@ -200,7 +227,7 @@ export interface DeferredBlockTriggers { viewport?: ViewportDeferredTrigger; } -export class DeferredBlock implements Node { +export class DeferredBlock extends BlockNode implements Node { readonly triggers: Readonly; readonly prefetchTriggers: Readonly; private readonly definedTriggers: (keyof DeferredBlockTriggers)[]; @@ -210,8 +237,9 @@ export class DeferredBlock implements Node { public children: Node[], triggers: DeferredBlockTriggers, prefetchTriggers: DeferredBlockTriggers, public placeholder: DeferredBlockPlaceholder|null, public loading: DeferredBlockLoading|null, public error: DeferredBlockError|null, - public sourceSpan: ParseSourceSpan, public mainBlockSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) { + nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, public mainBlockSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan|null) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); this.triggers = triggers; this.prefetchTriggers = prefetchTriggers; // We cache the keys since we know that they won't change and we @@ -239,25 +267,31 @@ export class DeferredBlock implements Node { } } -export class SwitchBlock implements Node { +export class SwitchBlock extends BlockNode implements Node { constructor( public expression: AST, public cases: SwitchBlockCase[], /** * These blocks are only captured to allow for autocompletion in the language service. They * aren't meant to be processed in any other way. */ - public unknownBlocks: UnknownBlock[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} + public unknownBlocks: UnknownBlock[], sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan|null, + nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitSwitchBlock(this); } } -export class SwitchBlockCase implements Node { +export class SwitchBlockCase extends BlockNode implements Node { constructor( - public expression: AST|null, public children: Node[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} + public expression: AST|null, public children: Node[], sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan|null, + nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitSwitchBlockCase(this); @@ -270,44 +304,53 @@ export class SwitchBlockCase implements Node { export type ForLoopBlockContext = Record<'$index'|'$first'|'$last'|'$even'|'$odd'|'$count', Variable>; -export class ForLoopBlock implements Node { +export class ForLoopBlock extends BlockNode implements Node { constructor( public item: Variable, public expression: ASTWithSource, public trackBy: ASTWithSource, - public contextVariables: ForLoopBlockContext, public children: Node[], - public empty: ForLoopBlockEmpty|null, public sourceSpan: ParseSourceSpan, - public mainBlockSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, - public endSourceSpan: ParseSourceSpan|null) {} + public trackKeywordSpan: ParseSourceSpan, public contextVariables: ForLoopBlockContext, + public children: Node[], public empty: ForLoopBlockEmpty|null, sourceSpan: ParseSourceSpan, + public mainBlockSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan|null, nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitForLoopBlock(this); } } -export class ForLoopBlockEmpty implements Node { +export class ForLoopBlockEmpty extends BlockNode implements Node { constructor( - public children: Node[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} + public children: Node[], sourceSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan|null, nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitForLoopBlockEmpty(this); } } -export class IfBlock implements Node { +export class IfBlock extends BlockNode implements Node { constructor( - public branches: IfBlockBranch[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan, public endSourceSpan: ParseSourceSpan|null) {} + public branches: IfBlockBranch[], sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan|null, + nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitIfBlock(this); } } -export class IfBlockBranch implements Node { +export class IfBlockBranch extends BlockNode implements Node { constructor( public expression: AST|null, public children: Node[], public expressionAlias: Variable|null, - public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, - public endSourceSpan: ParseSourceSpan|null) {} + sourceSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan|null, nameSpan: ParseSourceSpan) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } visit(visitor: Visitor): Result { return visitor.visitIfBlockBranch(this); diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index b5960f15baf99..81aa3f3f693c8 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -59,7 +59,8 @@ export function createIfBlock( if (mainBlockParams !== null) { branches.push(new t.IfBlockBranch( mainBlockParams.expression, html.visitAll(visitor, ast.children, ast.children), - mainBlockParams.expressionAlias, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan)); + mainBlockParams.expressionAlias, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan, + ast.nameSpan)); } for (const block of connectedBlocks) { @@ -70,12 +71,13 @@ export function createIfBlock( const children = html.visitAll(visitor, block.children, block.children); branches.push(new t.IfBlockBranch( params.expression, children, params.expressionAlias, block.sourceSpan, - block.startSourceSpan, block.endSourceSpan)); + block.startSourceSpan, block.endSourceSpan, block.nameSpan)); } } else if (block.name === 'else') { const children = html.visitAll(visitor, block.children, block.children); branches.push(new t.IfBlockBranch( - null, children, null, block.sourceSpan, block.startSourceSpan, block.endSourceSpan)); + null, children, null, block.sourceSpan, block.startSourceSpan, block.endSourceSpan, + block.nameSpan)); } } @@ -92,7 +94,8 @@ export function createIfBlock( } return { - node: new t.IfBlock(branches, wholeSourceSpan, ast.startSourceSpan, ifBlockEndSourceSpan), + node: new t.IfBlock( + branches, wholeSourceSpan, ast.startSourceSpan, ifBlockEndSourceSpan, ast.nameSpan), errors, }; } @@ -115,7 +118,7 @@ export function createForLoop( } else { empty = new t.ForLoopBlockEmpty( html.visitAll(visitor, block.children, block.children), block.sourceSpan, - block.startSourceSpan, block.endSourceSpan); + block.startSourceSpan, block.endSourceSpan, block.nameSpan); } } else { errors.push(new ParseError(block.sourceSpan, `Unrecognized @for loop block "${block.name}"`)); @@ -135,9 +138,9 @@ export function createForLoop( const sourceSpan = new ParseSourceSpan(ast.sourceSpan.start, endSpan?.end ?? ast.sourceSpan.end); node = new t.ForLoopBlock( - params.itemName, params.expression, params.trackBy, params.context, - html.visitAll(visitor, ast.children, ast.children), empty, sourceSpan, ast.sourceSpan, - ast.startSourceSpan, endSpan); + params.itemName, params.expression, params.trackBy.expression, params.trackBy.keywordSpan, + params.context, html.visitAll(visitor, ast.children, ast.children), empty, sourceSpan, + ast.sourceSpan, ast.startSourceSpan, endSpan, ast.nameSpan); } } @@ -172,7 +175,7 @@ export function createSwitchBlock( null; const ast = new t.SwitchBlockCase( expression, html.visitAll(visitor, node.children, node.children), node.sourceSpan, - node.startSourceSpan, node.endSourceSpan); + node.startSourceSpan, node.endSourceSpan, node.nameSpan); if (expression === null) { defaultCase = ast; @@ -189,7 +192,7 @@ export function createSwitchBlock( return { node: new t.SwitchBlock( primaryExpression, cases, unknownBlocks, ast.sourceSpan, ast.startSourceSpan, - ast.endSourceSpan), + ast.endSourceSpan, ast.nameSpan), errors }; } @@ -217,7 +220,7 @@ function parseForLoopParameters( const result = { itemName: new t.Variable( itemName, '$implicit', expressionParam.sourceSpan, expressionParam.sourceSpan), - trackBy: null as ASTWithSource | null, + trackBy: null as {expression: ASTWithSource, keywordSpan: ParseSourceSpan} | null, expression: parseBlockParameterToBinding(expressionParam, bindingParser, rawExpression), context: {} as t.ForLoopBlockContext, }; @@ -237,7 +240,10 @@ function parseForLoopParameters( errors.push( new ParseError(param.sourceSpan, '@for loop can only have one "track" expression')); } else { - result.trackBy = parseBlockParameterToBinding(param, bindingParser, trackMatch[1]); + const expression = parseBlockParameterToBinding(param, bindingParser, trackMatch[1]); + const keywordSpan = new ParseSourceSpan( + param.sourceSpan.start, param.sourceSpan.start.moveBy('track'.length)); + result.trackBy = {expression, keywordSpan}; } continue; } diff --git a/packages/compiler/src/render3/r3_deferred_blocks.ts b/packages/compiler/src/render3/r3_deferred_blocks.ts index 9f6637941c0ba..5ef7548d4c350 100644 --- a/packages/compiler/src/render3/r3_deferred_blocks.ts +++ b/packages/compiler/src/render3/r3_deferred_blocks.ts @@ -48,8 +48,7 @@ export function createDeferredBlock( const {triggers, prefetchTriggers} = parsePrimaryTriggers(ast.parameters, bindingParser, errors, placeholder); - // The `defer` block has a main span encompassing all of the connected branches as well. For the - // span of only the first "main" branch, use `mainSourceSpan`. + // The `defer` block has a main span encompassing all of the connected branches as well. let lastEndSourceSpan = ast.endSourceSpan; let endOfLastSourceSpan = ast.sourceSpan.end; if (connectedBlocks.length > 0) { @@ -58,12 +57,13 @@ export function createDeferredBlock( endOfLastSourceSpan = lastConnectedBlock.sourceSpan.end; } - const mainDeferredSourceSpan = new ParseSourceSpan(ast.sourceSpan.start, endOfLastSourceSpan); + const sourceSpanWithConnectedBlocks = + new ParseSourceSpan(ast.sourceSpan.start, endOfLastSourceSpan); const node = new t.DeferredBlock( html.visitAll(visitor, ast.children, ast.children), triggers, prefetchTriggers, placeholder, - loading, error, mainDeferredSourceSpan, ast.sourceSpan, ast.startSourceSpan, - lastEndSourceSpan); + loading, error, ast.nameSpan, sourceSpanWithConnectedBlocks, ast.sourceSpan, + ast.startSourceSpan, lastEndSourceSpan); return {node, errors}; } @@ -140,7 +140,7 @@ function parsePlaceholderBlock(ast: html.Block, visitor: html.Visitor): t.Deferr } return new t.DeferredBlockPlaceholder( - html.visitAll(visitor, ast.children, ast.children), minimumTime, ast.sourceSpan, + html.visitAll(visitor, ast.children, ast.children), minimumTime, ast.nameSpan, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan); } @@ -181,8 +181,8 @@ function parseLoadingBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBl } return new t.DeferredBlockLoading( - html.visitAll(visitor, ast.children, ast.children), afterTime, minimumTime, ast.sourceSpan, - ast.startSourceSpan, ast.endSourceSpan); + html.visitAll(visitor, ast.children, ast.children), afterTime, minimumTime, ast.nameSpan, + ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan); } @@ -192,8 +192,8 @@ function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBloc } return new t.DeferredBlockError( - html.visitAll(visitor, ast.children, ast.children), ast.sourceSpan, ast.startSourceSpan, - ast.endSourceSpan); + html.visitAll(visitor, ast.children, ast.children), ast.nameSpan, ast.sourceSpan, + ast.startSourceSpan, ast.endSourceSpan); } function parsePrimaryTriggers( diff --git a/packages/compiler/src/render3/r3_deferred_triggers.ts b/packages/compiler/src/render3/r3_deferred_triggers.ts index c89a0a55fde15..fb28325ccb8be 100644 --- a/packages/compiler/src/render3/r3_deferred_triggers.ts +++ b/packages/compiler/src/render3/r3_deferred_triggers.ts @@ -42,6 +42,9 @@ export function parseWhenTrigger( {expression, sourceSpan}: html.BlockParameter, bindingParser: BindingParser, triggers: t.DeferredBlockTriggers, errors: ParseError[]): void { const whenIndex = expression.indexOf('when'); + const whenSourceSpan = new ParseSourceSpan( + sourceSpan.start.moveBy(whenIndex), sourceSpan.start.moveBy(whenIndex + 'when'.length)); + const prefetchSpan = getPrefetchSpan(expression, sourceSpan); // This is here just to be safe, we shouldn't enter this function // in the first place if a block doesn't have the "when" keyword. @@ -51,7 +54,9 @@ export function parseWhenTrigger( const start = getTriggerParametersStart(expression, whenIndex + 1); const parsed = bindingParser.parseBinding( expression.slice(start), false, sourceSpan, sourceSpan.start.offset + start); - trackTrigger('when', triggers, errors, new t.BoundDeferredTrigger(parsed, sourceSpan)); + trackTrigger( + 'when', triggers, errors, + new t.BoundDeferredTrigger(parsed, sourceSpan, prefetchSpan, whenSourceSpan)); } } @@ -60,6 +65,9 @@ export function parseOnTrigger( {expression, sourceSpan}: html.BlockParameter, triggers: t.DeferredBlockTriggers, errors: ParseError[], placeholder: t.DeferredBlockPlaceholder|null): void { const onIndex = expression.indexOf('on'); + const onSourceSpan = new ParseSourceSpan( + sourceSpan.start.moveBy(onIndex), sourceSpan.start.moveBy(onIndex + 'on'.length)); + const prefetchSpan = getPrefetchSpan(expression, sourceSpan); // This is here just to be safe, we shouldn't enter this function // in the first place if a block doesn't have the "on" keyword. @@ -67,12 +75,19 @@ export function parseOnTrigger( errors.push(new ParseError(sourceSpan, `Could not find "on" keyword in expression`)); } else { const start = getTriggerParametersStart(expression, onIndex + 1); - const parser = - new OnTriggerParser(expression, start, sourceSpan, triggers, errors, placeholder); + const parser = new OnTriggerParser( + expression, start, sourceSpan, triggers, errors, placeholder, prefetchSpan, onSourceSpan); parser.parse(); } } +function getPrefetchSpan(expression: string, sourceSpan: ParseSourceSpan) { + if (!expression.startsWith('prefetch')) { + return null; + } + return new ParseSourceSpan(sourceSpan.start, sourceSpan.start.moveBy('prefetch'.length)); +} + class OnTriggerParser { private index = 0; @@ -81,7 +96,8 @@ class OnTriggerParser { constructor( private expression: string, private start: number, private span: ParseSourceSpan, private triggers: t.DeferredBlockTriggers, private errors: ParseError[], - private placeholder: t.DeferredBlockPlaceholder|null) { + private placeholder: t.DeferredBlockPlaceholder|null, + private prefetchSpan: ParseSourceSpan|null, private onSourceSpan: ParseSourceSpan) { this.tokens = new Lexer().tokenize(expression.slice(start)); } @@ -133,36 +149,66 @@ class OnTriggerParser { } private consumeTrigger(identifier: Token, parameters: string[]) { - const startSpan = this.span.start.moveBy(this.start + identifier.index - this.tokens[0].index); - const endSpan = startSpan.moveBy(this.token().end - identifier.index); - const sourceSpan = new ParseSourceSpan(startSpan, endSpan); + const triggerNameStartSpan = + this.span.start.moveBy(this.start + identifier.index - this.tokens[0].index); + const nameSpan = new ParseSourceSpan( + triggerNameStartSpan, triggerNameStartSpan.moveBy(identifier.strValue.length)); + const endSpan = triggerNameStartSpan.moveBy(this.token().end - identifier.index); + + // Put the prefetch and on spans with the first trigger + // This should maybe be refactored to have something like an outer OnGroup AST + // Since triggers can be grouped with commas "on hover(x), interaction(y)" + const isFirstTrigger = identifier.index === 0; + const onSourceSpan = isFirstTrigger ? this.onSourceSpan : null; + const prefetchSourceSpan = isFirstTrigger ? this.prefetchSpan : null; + const sourceSpan = + new ParseSourceSpan(isFirstTrigger ? this.span.start : triggerNameStartSpan, endSpan); try { switch (identifier.toString()) { case OnTriggerType.IDLE: - this.trackTrigger('idle', createIdleTrigger(parameters, sourceSpan)); + this.trackTrigger( + 'idle', + createIdleTrigger( + parameters, nameSpan, sourceSpan, prefetchSourceSpan, onSourceSpan)); break; case OnTriggerType.TIMER: - this.trackTrigger('timer', createTimerTrigger(parameters, sourceSpan)); + this.trackTrigger( + 'timer', + createTimerTrigger( + parameters, nameSpan, sourceSpan, this.prefetchSpan, this.onSourceSpan)); break; case OnTriggerType.INTERACTION: this.trackTrigger( - 'interaction', createInteractionTrigger(parameters, sourceSpan, this.placeholder)); + 'interaction', + createInteractionTrigger( + parameters, nameSpan, sourceSpan, this.prefetchSpan, this.onSourceSpan, + this.placeholder)); break; case OnTriggerType.IMMEDIATE: - this.trackTrigger('immediate', createImmediateTrigger(parameters, sourceSpan)); + this.trackTrigger( + 'immediate', + createImmediateTrigger( + parameters, nameSpan, sourceSpan, this.prefetchSpan, this.onSourceSpan)); break; case OnTriggerType.HOVER: - this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan, this.placeholder)); + this.trackTrigger( + 'hover', + createHoverTrigger( + parameters, nameSpan, sourceSpan, this.prefetchSpan, this.onSourceSpan, + this.placeholder)); break; case OnTriggerType.VIEWPORT: this.trackTrigger( - 'viewport', createViewportTrigger(parameters, sourceSpan, this.placeholder)); + 'viewport', + createViewportTrigger( + parameters, nameSpan, sourceSpan, this.prefetchSpan, this.onSourceSpan, + this.placeholder)); break; default: @@ -273,15 +319,26 @@ function trackTrigger( } function createIdleTrigger( - parameters: string[], sourceSpan: ParseSourceSpan): t.IdleDeferredTrigger { + parameters: string[], + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, + onSourceSpan: ParseSourceSpan|null, + ): t.IdleDeferredTrigger { if (parameters.length > 0) { throw new Error(`"${OnTriggerType.IDLE}" trigger cannot have parameters`); } - return new t.IdleDeferredTrigger(sourceSpan); + return new t.IdleDeferredTrigger(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } -function createTimerTrigger(parameters: string[], sourceSpan: ParseSourceSpan) { +function createTimerTrigger( + parameters: string[], + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, + onSourceSpan: ParseSourceSpan|null, +) { if (parameters.length !== 1) { throw new Error(`"${OnTriggerType.TIMER}" trigger must have exactly one parameter`); } @@ -292,37 +349,48 @@ function createTimerTrigger(parameters: string[], sourceSpan: ParseSourceSpan) { throw new Error(`Could not parse time value of trigger "${OnTriggerType.TIMER}"`); } - return new t.TimerDeferredTrigger(delay, sourceSpan); + return new t.TimerDeferredTrigger(delay, nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } function createImmediateTrigger( - parameters: string[], sourceSpan: ParseSourceSpan): t.ImmediateDeferredTrigger { + parameters: string[], + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, + onSourceSpan: ParseSourceSpan|null, + ): t.ImmediateDeferredTrigger { if (parameters.length > 0) { throw new Error(`"${OnTriggerType.IMMEDIATE}" trigger cannot have parameters`); } - return new t.ImmediateDeferredTrigger(sourceSpan); + return new t.ImmediateDeferredTrigger(nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } function createHoverTrigger( - parameters: string[], sourceSpan: ParseSourceSpan, + parameters: string[], nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null, placeholder: t.DeferredBlockPlaceholder|null): t.HoverDeferredTrigger { validateReferenceBasedTrigger(OnTriggerType.HOVER, parameters, placeholder); - return new t.HoverDeferredTrigger(parameters[0] ?? null, sourceSpan); + return new t.HoverDeferredTrigger( + parameters[0] ?? null, nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } function createInteractionTrigger( - parameters: string[], sourceSpan: ParseSourceSpan, + parameters: string[], nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null, placeholder: t.DeferredBlockPlaceholder|null): t.InteractionDeferredTrigger { validateReferenceBasedTrigger(OnTriggerType.INTERACTION, parameters, placeholder); - return new t.InteractionDeferredTrigger(parameters[0] ?? null, sourceSpan); + return new t.InteractionDeferredTrigger( + parameters[0] ?? null, nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } function createViewportTrigger( - parameters: string[], sourceSpan: ParseSourceSpan, + parameters: string[], nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan|null, onSourceSpan: ParseSourceSpan|null, placeholder: t.DeferredBlockPlaceholder|null): t.ViewportDeferredTrigger { validateReferenceBasedTrigger(OnTriggerType.VIEWPORT, parameters, placeholder); - return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan); + return new t.ViewportDeferredTrigger( + parameters[0] ?? null, nameSpan, sourceSpan, prefetchSpan, onSourceSpan); } function validateReferenceBasedTrigger( diff --git a/packages/compiler/src/render3/view/t2_api.ts b/packages/compiler/src/render3/view/t2_api.ts index 8179595cd34eb..f4e9662948d02 100644 --- a/packages/compiler/src/render3/view/t2_api.ts +++ b/packages/compiler/src/render3/view/t2_api.ts @@ -213,5 +213,5 @@ export interface BoundTarget { * @param block Block that the trigger belongs to. * @param trigger Trigger whose target is being looked up. */ - getDeferredTriggerTarget(block: DeferredTrigger, trigger: DeferredTrigger): Element|null; + getDeferredTriggerTarget(block: DeferredBlock, trigger: DeferredTrigger): Element|null; } diff --git a/packages/compiler/test/render3/r3_ast_spans_spec.ts b/packages/compiler/test/render3/r3_ast_spans_spec.ts index 92cb8e9b43ea6..33b7abb782d9a 100644 --- a/packages/compiler/test/render3/r3_ast_spans_spec.ts +++ b/packages/compiler/test/render3/r3_ast_spans_spec.ts @@ -628,13 +628,13 @@ describe('R3 AST source spans', () => { '}' ], ['BoundDeferredTrigger', 'when isVisible() && foo'], - ['HoverDeferredTrigger', 'hover(button)'], + ['HoverDeferredTrigger', 'on hover(button)'], ['TimerDeferredTrigger', 'timer(10s)'], ['IdleDeferredTrigger', 'idle'], ['ImmediateDeferredTrigger', 'immediate'], ['InteractionDeferredTrigger', 'interaction(button)'], ['ViewportDeferredTrigger', 'viewport(container)'], - ['ImmediateDeferredTrigger', 'immediate'], + ['ImmediateDeferredTrigger', 'prefetch on immediate'], ['BoundDeferredTrigger', 'prefetch when isDataLoaded()'], [ 'Element', '', '', diff --git a/packages/language-service/src/display_parts.ts b/packages/language-service/src/display_parts.ts index 71d7288f7a3d8..15eef06f251be 100644 --- a/packages/language-service/src/display_parts.ts +++ b/packages/language-service/src/display_parts.ts @@ -25,6 +25,7 @@ export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.tex export enum DisplayInfoKind { ATTRIBUTE = 'attribute', BLOCK = 'block', + TRIGGER = 'trigger', COMPONENT = 'component', DIRECTIVE = 'directive', EVENT = 'event', @@ -35,6 +36,7 @@ export enum DisplayInfoKind { PROPERTY = 'property', METHOD = 'method', TEMPLATE = 'template', + KEYWORD = 'keyword', } export interface DisplayInfo { diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index d04875ac19cc9..da636b31de320 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -165,8 +165,7 @@ export class LanguageService { const node = positionDetails.context.kind === TargetNodeKind.TwoWayBindingContext ? positionDetails.context.nodes[0] : positionDetails.context.node; - return new QuickInfoBuilder( - this.tsLS, compiler, templateInfo.component, node, positionDetails.parent) + return new QuickInfoBuilder(this.tsLS, compiler, templateInfo.component, node, positionDetails) .get(); } diff --git a/packages/language-service/src/outlining_spans.ts b/packages/language-service/src/outlining_spans.ts index a85db263711d1..b1bfe6362c44e 100644 --- a/packages/language-service/src/outlining_spans.ts +++ b/packages/language-service/src/outlining_spans.ts @@ -48,9 +48,7 @@ export function getOutliningSpans(compiler: NgCompiler, fileName: string): ts.Ou } class BlockVisitor extends t.RecursiveVisitor { - readonly blocks = [] as - Array; + readonly blocks = [] as Array; static getBlockSpans(templateNodes: t.Node[]): ts.OutliningSpan[] { const visitor = new BlockVisitor(); @@ -78,11 +76,9 @@ class BlockVisitor extends t.RecursiveVisitor { } visit(node: t.Node) { - if (node instanceof t.IfBlockBranch || node instanceof t.ForLoopBlockEmpty || - node instanceof t.ForLoopBlock || node instanceof t.SwitchBlockCase || - node instanceof t.SwitchBlock || node instanceof t.DeferredBlockError || - node instanceof t.DeferredBlockPlaceholder || node instanceof t.DeferredBlockLoading || - node instanceof t.DeferredBlock) { + if (node instanceof t.BlockNode + // Omit `IfBlock` because we include the branches individually + && !(node instanceof t.IfBlock)) { this.blocks.push(node); } } diff --git a/packages/language-service/src/quick_info.ts b/packages/language-service/src/quick_info.ts index eb99e28134688..8fd498c399ae1 100644 --- a/packages/language-service/src/quick_info.ts +++ b/packages/language-service/src/quick_info.ts @@ -5,23 +5,31 @@ * 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, Call, ImplicitReceiver, PropertyRead, ThisReceiver, TmplAstBoundAttribute, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler'; +import {AST, TmplAstBoundAttribute, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, InputBindingSymbol, OutputBindingSymbol, PipeSymbol, ReferenceSymbol, Symbol, SymbolKind, TcbLocation, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import {BlockNode, DeferredTrigger} from '@angular/compiler/src/render3/r3_ast'; import ts from 'typescript'; -import {createDisplayParts, DisplayInfoKind, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; -import {filterAliasImports, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTextSpanOfNode} from './utils'; +import {DisplayInfoKind, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from './display_parts'; +import {createDollarAnyQuickInfo, createNgTemplateQuickInfo, createQuickInfoForBuiltIn, isDollarAny} from './quick_info_built_ins'; +import {TemplateTarget} from './template_target'; +import {createQuickInfo, filterAliasImports, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTextSpanOfNode} from './utils'; export class QuickInfoBuilder { private readonly typeChecker = this.compiler.getCurrentProgram().getTypeChecker(); + private readonly parent = this.positionDetails.parent; constructor( private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler, private readonly component: ts.ClassDeclaration, private node: TmplAstNode|AST, - private parent: TmplAstNode|AST|null) {} + private readonly positionDetails: TemplateTarget) {} get(): ts.QuickInfo|undefined { + if (this.node instanceof DeferredTrigger || this.node instanceof BlockNode) { + return createQuickInfoForBuiltIn(this.node, this.positionDetails.position); + } + const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(this.node, this.component); if (symbol !== null) { @@ -194,63 +202,3 @@ function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: DisplayInfoKind): ts function displayPartsEqual(a: {text: string, kind: string}, b: {text: string, kind: string}) { return a.text === b.text && a.kind === b.kind; } - -function isDollarAny(node: TmplAstNode|AST): node is Call { - return node instanceof Call && node.receiver instanceof PropertyRead && - node.receiver.receiver instanceof ImplicitReceiver && - !(node.receiver.receiver instanceof ThisReceiver) && node.receiver.name === '$any' && - node.args.length === 1; -} - -function createDollarAnyQuickInfo(node: Call): ts.QuickInfo { - return createQuickInfo( - '$any', - DisplayInfoKind.METHOD, - getTextSpanOfNode(node.receiver), - /** containerName */ undefined, - 'any', - [{ - kind: SYMBOL_TEXT, - text: 'function to cast an expression to the `any` type', - }], - ); -} - -// TODO(atscott): Create special `ts.QuickInfo` for `ng-template` and `ng-container` as well. -function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo { - return createQuickInfo( - 'ng-template', - DisplayInfoKind.TEMPLATE, - getTextSpanOfNode(node), - /** containerName */ undefined, - /** type */ undefined, - [{ - kind: SYMBOL_TEXT, - text: - 'The `` is an Angular element for rendering HTML. It is never displayed directly.', - }], - ); -} - -/** - * Construct a QuickInfo object taking into account its container and type. - * @param name Name of the QuickInfo target - * @param kind component, directive, pipe, etc. - * @param textSpan span of the target - * @param containerName either the Symbol's container or the NgModule that contains the directive - * @param type user-friendly name of the type - * @param documentation docstring or comment - */ -export function createQuickInfo( - name: string, kind: DisplayInfoKind, textSpan: ts.TextSpan, containerName?: string, - type?: string, documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo { - const displayParts = createDisplayParts(name, kind, containerName, type); - - return { - kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), - kindModifiers: ts.ScriptElementKindModifier.none, - textSpan: textSpan, - displayParts, - documentation, - }; -} diff --git a/packages/language-service/src/quick_info_built_ins.ts b/packages/language-service/src/quick_info_built_ins.ts new file mode 100644 index 0000000000000..4179dee9a92a1 --- /dev/null +++ b/packages/language-service/src/quick_info_built_ins.ts @@ -0,0 +1,182 @@ +/** + * @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, Call, ImplicitReceiver, ParseSourceSpan, PropertyRead, ThisReceiver, TmplAstDeferredBlock, TmplAstDeferredBlockError, TmplAstDeferredBlockLoading, TmplAstDeferredBlockPlaceholder, TmplAstNode} from '@angular/compiler'; +import {BlockNode, DeferredTrigger, ForLoopBlock, ForLoopBlockEmpty} from '@angular/compiler/src/render3/r3_ast'; +import ts from 'typescript'; + +import {DisplayInfoKind, SYMBOL_TEXT} from './display_parts'; +import {createQuickInfo, getTextSpanOfNode, isWithin, toTextSpan} from './utils'; + +export function isDollarAny(node: TmplAstNode|AST): node is Call { + return node instanceof Call && node.receiver instanceof PropertyRead && + node.receiver.receiver instanceof ImplicitReceiver && + !(node.receiver.receiver instanceof ThisReceiver) && node.receiver.name === '$any' && + node.args.length === 1; +} + +export function createDollarAnyQuickInfo(node: Call): ts.QuickInfo { + return createQuickInfo( + '$any', + DisplayInfoKind.METHOD, + getTextSpanOfNode(node.receiver), + /** containerName */ undefined, + 'any', + [{ + kind: SYMBOL_TEXT, + text: 'function to cast an expression to the `any` type', + }], + ); +} + +// TODO(atscott): Create special `ts.QuickInfo` for `ng-template` and `ng-container` as well. +export function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo { + return createQuickInfo( + 'ng-template', + DisplayInfoKind.TEMPLATE, + getTextSpanOfNode(node), + /** containerName */ undefined, + /** type */ undefined, + [{ + kind: SYMBOL_TEXT, + text: + 'The `` is an Angular element for rendering HTML. It is never displayed directly.', + }], + ); +} + +export function createQuickInfoForBuiltIn( + node: DeferredTrigger|BlockNode, cursorPositionInTemplate: number): ts.QuickInfo|undefined { + let partSpan: ParseSourceSpan; + if (node instanceof DeferredTrigger) { + if (node.prefetchSpan !== null && isWithin(cursorPositionInTemplate, node.prefetchSpan)) { + partSpan = node.prefetchSpan; + } else if ( + node.whenOrOnSourceSpan !== null && + isWithin(cursorPositionInTemplate, node.whenOrOnSourceSpan)) { + partSpan = node.whenOrOnSourceSpan; + } else if (node.nameSpan !== null && isWithin(cursorPositionInTemplate, node.nameSpan)) { + partSpan = node.nameSpan; + } else { + return undefined; + } + } else { + if (node instanceof TmplAstDeferredBlock || node instanceof TmplAstDeferredBlockError || + node instanceof TmplAstDeferredBlockLoading || + node instanceof TmplAstDeferredBlockPlaceholder || + node instanceof ForLoopBlockEmpty && isWithin(cursorPositionInTemplate, node.nameSpan)) { + partSpan = node.nameSpan; + } else if ( + node instanceof ForLoopBlock && isWithin(cursorPositionInTemplate, node.trackKeywordSpan)) { + partSpan = node.trackKeywordSpan; + } else { + return undefined; + } + } + + const partName = partSpan.toString().trim(); + const partInfo = BUILT_IN_NAMES_TO_DOC_MAP[partName]; + const linkTags: ts.JSDocTagInfo[] = + (partInfo?.links ?? []).map(text => ({text: [{kind: SYMBOL_TEXT, text}], name: 'see'})); + return createQuickInfo( + partName, + partInfo.displayInfoKind, + toTextSpan(partSpan), + /** containerName */ undefined, + /** type */ undefined, + [{ + kind: SYMBOL_TEXT, + text: partInfo?.docString ?? '', + }], + linkTags, + ); +} + +const triggerDescriptionPreamble = 'A trigger to start loading the defer content after '; +const BUILT_IN_NAMES_TO_DOC_MAP: { + [name: string]: {docString: string, links: string[], displayInfoKind: DisplayInfoKind} +} = { + '@defer': { + docString: + `A type of block that can be used to defer load the JavaScript for components, directives and pipes used inside a component template.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.BLOCK, + }, + '@placeholder': { + docString: `A block for content shown prior to defer loading (Optional)`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.BLOCK, + }, + '@error': { + docString: `A block for content shown when defer loading errors occur (Optional)`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.BLOCK, + }, + '@loading': { + docString: `A block for content shown during defer loading (Optional)`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.BLOCK, + }, + '@empty': { + docString: `A block to display when the for loop variable is empty.`, + links: ['[AIO Reference](https://next.angular.io/api/core/for)'], + displayInfoKind: DisplayInfoKind.BLOCK, + }, + 'track': { + docString: `Keyword to control how the for loop compares items in the list to compute updates.`, + links: ['[AIO Reference](https://next.angular.io/api/core/for)'], + displayInfoKind: DisplayInfoKind.KEYWORD, + }, + 'idle': { + docString: triggerDescriptionPreamble + `the browser reports idle state (default).`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'immediate': { + docString: triggerDescriptionPreamble + `the page finishes rendering.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'hover': { + docString: triggerDescriptionPreamble + `the element has been hovered.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'timer': { + docString: triggerDescriptionPreamble + `a specific timeout.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'interaction': { + docString: triggerDescriptionPreamble + `the element is clicked, touched, or focused.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'viewport': { + docString: triggerDescriptionPreamble + `the element enters the viewport.`, + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.TRIGGER, + }, + 'prefetch': { + docString: + 'Keyword that indicates that the trigger configures when prefetching the defer block contents should start. You can use `on` and `when` conditions as prefetch triggers.', + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.KEYWORD, + }, + 'when': { + docString: + 'Keyword that starts the expression-based trigger section. Should be followed by an expression that returns a boolean.', + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.KEYWORD, + }, + 'on': { + docString: + 'Keyword that starts the event-based trigger section. Should be followed by one of the built-in triggers.', + links: ['[AIO Reference](https://next.angular.io/api/core/defer)'], + displayInfoKind: DisplayInfoKind.KEYWORD, + }, +}; diff --git a/packages/language-service/src/utils.ts b/packages/language-service/src/utils.ts index 99ed826f39187..edd809fe5e0fd 100644 --- a/packages/language-service/src/utils.ts +++ b/packages/language-service/src/utils.ts @@ -15,7 +15,7 @@ import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expr import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST import ts from 'typescript'; -import {ALIAS_NAME, SYMBOL_PUNC} from './display_parts'; +import {ALIAS_NAME, createDisplayParts, DisplayInfoKind, SYMBOL_PUNC, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; import {findTightestNode, getParentClassDeclaration} from './ts_utils'; export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { @@ -402,3 +402,27 @@ export function isBoundEventWithSyntheticHandler(event: t.BoundEvent): boolean { } return false; } + +/** + * Construct a QuickInfo object taking into account its container and type. + * @param name Name of the QuickInfo target + * @param kind component, directive, pipe, etc. + * @param textSpan span of the target + * @param containerName either the Symbol's container or the NgModule that contains the directive + * @param type user-friendly name of the type + * @param documentation docstring or comment + */ +export function createQuickInfo( + name: string, kind: DisplayInfoKind, textSpan: ts.TextSpan, containerName?: string, + type?: string, documentation?: ts.SymbolDisplayPart[], tags?: ts.JSDocTagInfo[]): ts.QuickInfo { + const displayParts = createDisplayParts(name, kind, containerName, type); + + return { + kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), + kindModifiers: ts.ScriptElementKindModifier.none, + textSpan: textSpan, + displayParts, + documentation, + tags, + }; +} diff --git a/packages/language-service/test/quick_info_spec.ts b/packages/language-service/test/quick_info_spec.ts index b08110269f512..22575d6e29489 100644 --- a/packages/language-service/test/quick_info_spec.ts +++ b/packages/language-service/test/quick_info_spec.ts @@ -522,6 +522,148 @@ describe('quick info', () => { }); }); + describe('blocks', () => { + describe('defer & friends', () => { + it('defer', () => { + expectQuickInfo({ + templateOverride: `@de¦fer { } @placeholder { }`, + expectedSpanText: '@defer ', + expectedDisplayString: '(block) @defer' + }); + }); + + it('defer with condition', () => { + expectQuickInfo({ + templateOverride: `@de¦fer (on immediate) { } @placeholder { }`, + expectedSpanText: '@defer ', + expectedDisplayString: '(block) @defer' + }); + }); + + it('placeholder', () => { + expectQuickInfo({ + templateOverride: `@defer { } @pla¦ceholder { }`, + expectedSpanText: '@placeholder ', + expectedDisplayString: '(block) @placeholder' + }); + }); + + it('loading', () => { + expectQuickInfo({ + templateOverride: `@defer { } @loadin¦g { }`, + expectedSpanText: '@loading ', + expectedDisplayString: '(block) @loading' + }); + }); + + it('error', () => { + expectQuickInfo({ + templateOverride: `@defer { } @erro¦r { }`, + expectedSpanText: '@error ', + expectedDisplayString: '(block) @error' + }); + }); + + describe('triggers', () => { + it('viewport', () => { + expectQuickInfo({ + templateOverride: `@defer (on vie¦wport(x)) { }
    `, + expectedSpanText: 'viewport', + expectedDisplayString: '(trigger) viewport' + }); + }); + + it('immediate', () => { + expectQuickInfo({ + templateOverride: `@defer (on imme¦diate) {}`, + expectedSpanText: 'immediate', + expectedDisplayString: '(trigger) immediate' + }); + }); + + it('idle', () => { + expectQuickInfo({ + templateOverride: `@defer (on i¦dle) { } `, + expectedSpanText: 'idle', + expectedDisplayString: '(trigger) idle' + }); + }); + + it('hover', () => { + expectQuickInfo({ + templateOverride: `@defer (on hov¦er(x)) { }
    `, + expectedSpanText: 'hover', + expectedDisplayString: '(trigger) hover' + }); + }); + + it('timer', () => { + expectQuickInfo({ + templateOverride: `@defer (on tim¦er(100)) { } `, + expectedSpanText: 'timer', + expectedDisplayString: '(trigger) timer' + }); + }); + + it('interaction', () => { + expectQuickInfo({ + templateOverride: `@defer (on interactio¦n(x)) { }
    `, + expectedSpanText: 'interaction', + expectedDisplayString: '(trigger) interaction' + }); + }); + + it('when', () => { + expectQuickInfo({ + templateOverride: `@defer (whe¦n title) { }
    `, + expectedSpanText: 'when', + expectedDisplayString: '(keyword) when' + }); + }); + + it('prefetch (when)', () => { + expectQuickInfo({ + templateOverride: `@defer (prefet¦ch when title) { }`, + expectedSpanText: 'prefetch', + expectedDisplayString: '(keyword) prefetch' + }); + }); + + it('on', () => { + expectQuickInfo({ + templateOverride: `@defer (o¦n immediate) { } `, + expectedSpanText: 'on', + expectedDisplayString: '(keyword) on' + }); + }); + + it('prefetch (on)', () => { + expectQuickInfo({ + templateOverride: `@defer (prefet¦ch on immediate) { }`, + expectedSpanText: 'prefetch', + expectedDisplayString: '(keyword) prefetch' + }); + }); + }); + }); + + it('empty', () => { + expectQuickInfo({ + templateOverride: `@for (name of constNames; track $index) {} @em¦pty {}`, + expectedSpanText: '@empty ', + expectedDisplayString: '(block) @empty' + }); + }); + + it('track keyword', () => { + expectQuickInfo({ + templateOverride: `@for (name of constNames; tr¦ack $index) {}`, + expectedSpanText: 'track', + expectedDisplayString: '(keyword) track' + }); + }); + }); + it('should work for object literal with shorthand property declarations', () => { initMockFileSystem('Native'); env = LanguageServiceTestEnv.setup(); From df1b44e1efa4b873813563dcc3b8f9c393aa0813 Mon Sep 17 00:00:00 2001 From: Francesco Donzello Date: Thu, 26 Oct 2023 12:17:14 +0200 Subject: [PATCH 24/52] docs: added missing imports for toSignal and toObservable (#52393) PR Close #52393 --- aio/content/guide/rxjs-interop.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aio/content/guide/rxjs-interop.md b/aio/content/guide/rxjs-interop.md index 71a6da7b17f0d..396dcda775ec9 100644 --- a/aio/content/guide/rxjs-interop.md +++ b/aio/content/guide/rxjs-interop.md @@ -16,6 +16,7 @@ The `toSignal` function creates a signal which tracks the value of an Observable import {Component} from '@angular/core'; import {AsyncPipe} from '@angular/common'; import {interval} from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ standalone: true, @@ -68,6 +69,7 @@ The `toObservable` utility creates an `Observable` which tracks the value of a s ```ts import { Component, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; @Component(...) export class SearchResults { From c3d60ff603c74ec8ba873f3ffb9f1d3169362bbe Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 26 Oct 2023 10:45:02 -0700 Subject: [PATCH 25/52] refactor(language-service): Improve autocomplete snippet of `for` block (#52405) The `for` block has several parts which we know are required. This commit improves the autocomplete snippet of the `for` block by adding those required parts and providing placeholders. PR Close #52405 --- packages/language-service/src/completions.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index 006f8fcc9ed5d..0253620ed1ec3 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -42,14 +42,17 @@ export enum CompletionNodeContext { const ANIMATION_PHASES = ['start', 'done']; -function buildBlockSnippet(insertSnippet: boolean, text: string, withParens: boolean): string { +function buildBlockSnippet(insertSnippet: boolean, blockName: string, withParens: boolean): string { if (!insertSnippet) { - return text; + return blockName; + } + if (blockName === 'for') { + return `${blockName} (\${1:item} of \${2:items}; track \${3:\\$index}) {$4}`; } if (withParens) { - return `${text} ($1) {$2}`; + return `${blockName} ($1) {$2}`; } - return `${text} {$1}`; + return `${blockName} {$1}`; } /** From b2d1e5cef44772b3d678a7dcce91ac47c825f94e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:04:58 -0400 Subject: [PATCH 26/52] docs: update esbuild guide for v17 (#52407) Update new build system guide to reflect stable status and the use of the `application` builder in existing projects. PR Close #52407 --- aio/content/guide/esbuild.md | 159 +++++++++++++++++++++++------------ 1 file changed, 107 insertions(+), 52 deletions(-) diff --git a/aio/content/guide/esbuild.md b/aio/content/guide/esbuild.md index 1db9992aa0371..a8f4f069442b1 100644 --- a/aio/content/guide/esbuild.md +++ b/aio/content/guide/esbuild.md @@ -1,28 +1,49 @@ -# Getting started with the CLI's esbuild-based build system +# Getting started with the Angular CLI's new build system + +In v17 and higher, the new build system provides an improved way to build Angular applications. This new build system includes: + +- A modern output format using ESM, with dynamic import expressions to support lazy module loading. +- Faster build-time performance for both initial builds and incremental rebuilds. +- Newer JavaScript ecosystem tools such as [esbuild](https://esbuild.github.io/) and [Vite](https://vitejs.dev/). +- Integrated SSR and prerendering capabilites + +This new build system is stable and fully supported for use with Angular applications. +You can migrate to the new build system with applications that use the `browser` builder. +If using a custom builder, please refer to the documentation for that builder on possible migration options.
    -The esbuild-based ECMAScript module (ESM) application build system feature is available for [developer preview](/guide/releases#developer-preview). -It's ready for you to try, but it might change before it is stable and is not yet recommended for production builds. +The existing Webpack-based build system is still considered stable and fully supported. +Applications can continue to use the `browser` builder and will not be automatically migrated when updating.
    -In v16 and higher, the new build system provides a way to build Angular applications. This new build system includes: +## For new applications -- A modern output format using ESM, with dynamic import expressions to support lazy module loading. -- Faster build-time performance for both initial builds and incremental rebuilds. -- Newer JavaScript ecosystem tools such as [esbuild](https://esbuild.github.io/) and [Vite](https://vitejs.dev/). +New applications will use this new build system by default via the `application` builder. + +## For existing applications -You can opt-in to use the new builder on a per application basis with minimal configuration updates required. +For existing projects, you can opt-in to use the new builder on a per-application basis with two different options. +Both options are considered stable and fully supported by the Angular team. +The choice of which option to use is a factor of how many changes you will need to make to migrate and what new features you would like to use in the project. -## Trying the ESM build system in an Angular CLI application +Builder | Configuration Changes | Code Changes | Integrated SSR | +| :----- | :-------- | :------ | :------- | +| `application` | Multiple option changes required. If using SSR, additional targets will need to be updated. | Yes, if using SSR | Yes +| `browser-esbuild` | builder name only | No* | No -A new builder named `browser-esbuild` is available within the `@angular-devkit/build-angular` package that is present in an Angular CLI generated application. The build is a drop-in replacement for the existing `browser` builder that provides the current stable browser application build system. -You can try out the new build system for applications that use the `browser` builder. +The `application` builder is generally preferred as it improves server-side rendered (SSR) builds, and makes it easier for client-side rendered projects to adopt SSR in the future. +However it requires a little more migration effort, particularly for existing SSR applications. +If the `application` builder is difficult for your project to adopt, `browser-esbuild` can be an easier solution which gives most of the build performance benefits with fewer breaking changes. -### Updating the application configuration +### Using the `browser-esbuild` builder -The new build system was implemented to minimize the amount of changes necessary to transition your applications. Currently, the new build system is provided via an alternate builder (`browser-esbuild`). You can update the `build` target for any application target to try out the new build system. +A builder named `browser-esbuild` is available within the `@angular-devkit/build-angular` package that is present in an Angular CLI generated application. The builder is a drop-in replacement for the existing `browser` builder that provides the preexisting browser application build system. + +The compatiblity option was implemented to minimize the amount of changes necessary to initially migrate your applications. +This is provided via an alternate builder (`browser-esbuild`). +You can update the `build` target for any application target to migrate to the new build system. The following is what you would typically find in `angular.json` for an application: @@ -44,9 +65,68 @@ Changing the `builder` field is the only change you will need to make. ... -### Executing a build +### Using the `application` builder + +A builder named `application` is also available within the `@angular-devkit/build-angular` package that is present in an Angular CLI generated application. +This builder is the default for all new applications created via `ng new`. -Once you have updated the application configuration, builds can be performed using the `ng build` as was previously done. For the remaining options that are currently not yet implemented in the developer preview, a warning will be issued for each and the option will be ignored during the build. +The following is what you would typically find in `angular.json` for an application: + + +... +"architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", +... + + +Changing the `builder` field is the first change you will need to make. + + +... +"architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", +... + + +Once the builder name has been changed, options within the `build` target will need to be updated. +The following table lists all the `browser` builder options that will need to be adjusted or removed. + +| `browser` Option | Action | Notes | +| :-------------- | :----- | :----- | +| `main` | rename option to `browser` | | +| `polyfills` | convert value to an array | may already have been migrated | +| `buildOptimizer` | remove option | +| `resourcesOutputPath` | remove option | always `media` | +| `vendorChunk` | remove option | +| `commonChunk` | remove option | +| `deployUrl` | remove option | +| `ngswConfigPath` | move value to `serviceWorker` and remove option | `serviceWorker` is now either `false` or a configuration path + + +If the application is not using SSR currently, this should be the final step to allow `ng build` to function. +After executing `ng build` for the first time, there may be new warnings or errors based on behavioral differences or application usage of Webpack-specific features. +Many of the warnings will provide suggestions on how to remedy that problem. +If it appears that a warning is incorrect or the solution is not apparent, please open an issue on [GitHub](https://github.com/angular/angular-cli/issues). +Also, the later sections of this guide provide additional information on several specific cases as well as current known issues. + +For applications that are already using SSR, additional manual adjustments to code will be needed to update the SSR server code to support the new integrated SSR capabilities. +The `application` builder now provides the integrated functionality for all of the following preexisting builders: + +* `app-shell` +* `prerender` +* `server` +* `ssr-dev-server` + +The [Angular SSR Guide](/guide/ssr) provides additional information regarding the new setup process for SSR. + +## Executing a build + +Once you have updated the application configuration, builds can be performed using the `ng build` as was previously done. +Depending on the choice of builder migration, some of the command line options may be different. +If the build command is contained in any `npm` or other scripts, ensure they are reviewed and updated. +For applications that have migrated to the `application` builder and that use SSR and/or prererending, you also may be able to remove extra `ng run` commands from scripts now that `ng build` has integrated SSR support. @@ -54,9 +134,10 @@ ng build -### Starting the development server +## Starting the development server -The development server now has the ability to automatically detect the new build system and use it to build the application. To start the development server no changes are necessary to the `dev-server` builder configuration or command line. +The development server will automatically detect the new build system and use it to build the application. +To start the development server no changes are necessary to the `dev-server` builder configuration or command line. @@ -68,22 +149,21 @@ You can continue to use the [command line options](/cli/serve) you have used in
    -The developer preview currently does not provide HMR support and the HMR related options will be ignored if used. Angular focused HMR capabilities are currently planned and will be introduced in a future version. +JavaScript-based Hot Module Replacement (HMR) is currently not supported. +However, global stylesheet (`styles` build option) HMR is available and enabled by default. +Angular focused HMR capabilities are currently planned and will be introduced in a future version.
    -### Unimplemented options and behavior +## Unimplemented options and behavior Several build options are not yet implemented but will be added in the future as the build system moves towards a stable status. If your application uses these options, you can still try out the build system without removing them. Warnings will be issued for any unimplemented options but they will otherwise be ignored. However, if your application relies on any of these options to function, you may want to wait to try. -- [Bundle budgets](https://github.com/angular/angular-cli/issues/25100) (`budgets`) -- [Localization](https://github.com/angular/angular-cli/issues/25099) (`localize`/`i18nDuplicateTranslation`/`i18nMissingTranslation`) -- [Web workers](https://github.com/angular/angular-cli/issues/25101) (`webWorkerTsConfig`) - [WASM imports](https://github.com/angular/angular-cli/issues/25102) -- WASM can still be loaded manually via [standard web APIs](https://developer.mozilla.org/en-US/docs/WebAssembly/Loading_and_running). Building libraries with the new build system via `ng-packagr` is also not yet possible but library build support will be available in a future release. -### ESM default imports vs. namespace imports +## ESM default imports vs. namespace imports TypeScript by default allows default exports to be imported as namespace imports and then used in call expressions. This is unfortunately a divergence from the ECMAScript specification. The underlying bundler (`esbuild`) within the new build system expects ESM code that conforms to the specification. The build system will now generate a warning if your application uses an incorrect type of import of a package. However, to allow TypeScript to accept the correct usage, a TypeScript option must be enabled within the application's `tsconfig` file. When enabled, the [`esModuleInterop`](https://www.typescriptlang.org/tsconfig#esModuleInterop) option provides better alignment with the ECMAScript specification and is also recommended by the TypeScript team. Once enabled, you can update package imports where applicable to an ECMAScript conformant form. @@ -129,28 +209,11 @@ The usage of Vite in the Angular CLI is currently only within a _development ser There are currently several known issues that you may encounter when trying the new build system. This list will be updated to stay current. If any of these issues are currently blocking you from trying out the new build system, please check back in the future as it may have been solved. -### Runtime-evaluated dynamic import expressions - -Dynamic import expressions that do not contain static values will be kept in their original form and not processed at build time. This is a limitation of the underlying bundler but is [planned](https://github.com/evanw/esbuild/pull/2508) to be implemented in the future. In many cases, application code can be made to work by changing the import expressions into static strings with some form of conditional statement such as an `if` or `switch` for the known potential files. - -Unsupported: - -```ts -return await import(`/abc/${name}.json`); -``` - -Supported: +### Type-checking of Web Worker code and processing of nested Web Workers -```ts -switch (name) { - case 'x': - return await import('/abc/x.json'); - case 'y': - return await import('/abc/y.json'); - case 'z': - return await import('/abc/z.json'); -} -``` +Web Workers can be used within application code using the same syntax (`new Worker(new URL('', import.meta.url))`) that is supported with the `browser` builder. +However, the code within the Worker will not currently be type-checked by the TypeScript compiler. TypeScript code is supported just not type-checked. +Additionally, any nested workers will not be processed by the build system. A nested worker is a Worker instantiation within another Worker file. ### Order-dependent side-effectful imports in lazy modules @@ -164,14 +227,6 @@ Avoiding the use of modules with non-local side effects (outside of polyfills) i -### Long build times when using Sass combined with pnpm or yarn PnP - -Applications may have increased build times due to the need to workaround Sass resolution incompatibilities when using either the pnpm or Yarn PnP package managers. -Sass files with `@import` or `@use` directives referencing a package when using either of these package managers can trigger the performance problem. - -An alternative workaround that alleviates the build time increases is in development and will be available before the build system moves to stable status. -Both the Yarn package manager in node modules mode and the `npm` package manager are not affected by this problem. - ## Bug reports Report issues and feature requests on [GitHub](https://github.com/angular/angular-cli/issues). From 62161a630f0dc28e74e548d883883346d19d8bc5 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 27 Oct 2023 08:24:22 -0700 Subject: [PATCH 27/52] refactor(core): global epoch to optimize non-live signal reads (#52420) This commit adds a global epoch to the reactive graph, which can optimize non-live reads. When a non-live read occurs, a computed must poll its dependencies to check if they've changed, and this operation is transitive and not cacheable. Since non-live computeds don't receive dirty notifications, they're forced to assume potential dirtiness on each and every read. Using a global epoch, we can add an important optimization: if *no* signals have been set globally since the last time it polled its dependencies, then we *can* assume a clean state. This significantly improves performance of large unwatched graphs when repeatedly reading values. PR Close #52420 --- .../core/primitives/signals/index.md | 1 + packages/core/primitives/signals/src/graph.ts | 32 +++++++++++++++++ .../core/primitives/signals/src/signal.ts | 3 +- packages/core/test/signals/signal_spec.ts | 35 ++++++++++++++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/core/primitives/signals/index.md b/goldens/public-api/core/primitives/signals/index.md index 98e98c67066bb..352cee5631e97 100644 --- a/goldens/public-api/core/primitives/signals/index.md +++ b/goldens/public-api/core/primitives/signals/index.md @@ -64,6 +64,7 @@ export interface ReactiveNode { consumerMarkedDirty(node: unknown): void; consumerOnSignalRead(node: unknown): void; dirty: boolean; + lastCleanEpoch: Version; liveConsumerIndexOfThis: number[] | undefined; liveConsumerNode: ReactiveNode[] | undefined; nextProducerIndex: number; diff --git a/packages/core/primitives/signals/src/graph.ts b/packages/core/primitives/signals/src/graph.ts index 9a5e2b6254aca..71d29624bdf3b 100644 --- a/packages/core/primitives/signals/src/graph.ts +++ b/packages/core/primitives/signals/src/graph.ts @@ -21,6 +21,11 @@ let inNotificationPhase = false; type Version = number&{__brand: 'Version'}; +/** + * Global epoch counter. Incremented whenever a source signal is set. + */ +let epoch: Version = 1 as Version; + /** * Symbol used to tell `Signal`s apart from other functions. * @@ -52,6 +57,7 @@ export function isReactive(value: unknown): value is Reactive { export const REACTIVE_NODE: ReactiveNode = { version: 0 as Version, + lastCleanEpoch: 0 as Version, dirty: false, producerNode: undefined, producerLastReadVersion: undefined, @@ -88,6 +94,14 @@ export interface ReactiveNode { */ version: Version; + /** + * Epoch at which this node is verified to be clean. + * + * This allows skipping of some polling operations in the case where no signals have been set + * since this node was last read. + */ + lastCleanEpoch: Version; + /** * Whether this node (in its consumer capacity) is dirty. * @@ -231,6 +245,15 @@ export function producerAccessed(node: ReactiveNode): void { activeConsumer.producerLastReadVersion[idx] = node.version; } +/** + * Increment the global epoch counter. + * + * Called by source producers (that is, not computeds) whenever their values change. + */ +export function producerIncrementEpoch(): void { + epoch++; +} + /** * Ensure this producer's `version` is up-to-date. */ @@ -241,10 +264,18 @@ export function producerUpdateValueVersion(node: ReactiveNode): void { return; } + if (!node.dirty && node.lastCleanEpoch === epoch) { + // Even non-live consumers can skip polling if they previously found themselves to be clean at + // the current epoch, since their dependencies could not possibly have changed (such a change + // would've increased the epoch). + return; + } + if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { // None of our producers report a change since the last time they were read, so no // recomputation of our value is necessary, and we can consider ourselves clean. node.dirty = false; + node.lastCleanEpoch = epoch; return; } @@ -252,6 +283,7 @@ export function producerUpdateValueVersion(node: ReactiveNode): void { // After recomputing the value, we're no longer dirty. node.dirty = false; + node.lastCleanEpoch = epoch; } /** diff --git a/packages/core/primitives/signals/src/signal.ts b/packages/core/primitives/signals/src/signal.ts index f69a79b0b2870..b8fcfee581bbd 100644 --- a/packages/core/primitives/signals/src/signal.ts +++ b/packages/core/primitives/signals/src/signal.ts @@ -8,7 +8,7 @@ import {defaultEquals, ValueEqualityFn} from './equality'; import {throwInvalidWriteToSignalError} from './errors'; -import {producerAccessed, producerNotifyConsumers, producerUpdatesAllowed, REACTIVE_NODE, ReactiveNode, SIGNAL} from './graph'; +import {producerAccessed, producerIncrementEpoch, producerNotifyConsumers, producerUpdatesAllowed, REACTIVE_NODE, ReactiveNode, SIGNAL} from './graph'; /** * If set, called after `WritableSignal`s are updated. @@ -98,6 +98,7 @@ const SIGNAL_NODE: object = /* @__PURE__ */ (() => { function signalValueChanged(node: SignalNode): void { node.version++; + producerIncrementEpoch(); producerNotifyConsumers(node); postSignalSetFn?.(); } diff --git a/packages/core/test/signals/signal_spec.ts b/packages/core/test/signals/signal_spec.ts index cb11d352bb9e1..7335c0df252af 100644 --- a/packages/core/test/signals/signal_spec.ts +++ b/packages/core/test/signals/signal_spec.ts @@ -7,7 +7,7 @@ */ import {computed, signal} from '@angular/core'; -import {setPostSignalSetFn} from '@angular/core/primitives/signals'; +import {ReactiveNode, setPostSignalSetFn, SIGNAL} from '@angular/core/primitives/signals'; describe('signals', () => { it('should be a getter which reflects the set value', () => { @@ -104,6 +104,39 @@ describe('signals', () => { expect(double()).toBe(4); }); + describe('optimizations', () => { + it('should not repeatedly poll status of a non-live node if no signals have changed', () => { + const unrelated = signal(0); + const source = signal(1); + let computations = 0; + const derived = computed(() => { + computations++; + return source() * 2; + }); + + expect(derived()).toBe(2); + expect(computations).toBe(1); + + const sourceNode = source[SIGNAL] as ReactiveNode; + // Forcibly increment the version of the source signal. This will cause a mismatch during + // polling, and will force the derived signal to recompute if polled (which we should observe + // in this test). + sourceNode.version++; + + // Read the derived signal again. This should not recompute (even with the forced version + // update) as no signals have been set since the last read. + expect(derived()).toBe(2); + expect(computations).toBe(1); + + // Set the `unrelated` signal, which now means that `derived` should poll if read again. + // Because of the forced version, that poll will cause a recomputation which we will observe. + unrelated.set(1); + + expect(derived()).toBe(2); + expect(computations).toBe(2); + }); + }); + describe('post-signal-set functions', () => { let prevPostSignalSetFn: (() => void)|null = null; let log: number; From 49b037f8116d56520035a1cc0797b65f1b0e3ad9 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Fri, 27 Oct 2023 20:20:08 +0200 Subject: [PATCH 28/52] fix(http): Don't override the backend when using the InMemoryWebAPI (#52425) When using `withFetch`, the `PRIMARY_HTTP_BACKEND` token is set. The InMemory Backend services will also set that token. This means that providers order will matter and the latest on the list will be the one instantiated PR Close #52425 --- packages/common/http/src/private_export.ts | 2 +- .../http-client-in-memory-web-api-module.ts | 23 ++++--------- .../src/in-memory-web-api-module.ts | 15 ++++----- .../test/http-client-backend-service_spec.ts | 32 +++++++++++++++++-- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/common/http/src/private_export.ts b/packages/common/http/src/private_export.ts index d5b0ff7cb9f87..bc0c9866932a2 100644 --- a/packages/common/http/src/private_export.ts +++ b/packages/common/http/src/private_export.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ -export {HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS} from './interceptor'; +export {HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS, PRIMARY_HTTP_BACKEND as ɵPRIMARY_HTTP_BACKEND} from './interceptor'; diff --git a/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts b/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts index e2fe53ea15ca4..8b32b61bbc584 100644 --- a/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts +++ b/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts @@ -6,21 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {XhrFactory} from '@angular/common'; -import {HttpBackend} from '@angular/common/http'; +import {HttpBackend, ɵPRIMARY_HTTP_BACKEND as PRIMARY_HTTP_BACKEND} from '@angular/common/http'; import {ModuleWithProviders, NgModule, Type} from '@angular/core'; import {HttpClientBackendService} from './http-client-backend-service'; import {InMemoryBackendConfig, InMemoryBackendConfigArgs, InMemoryDbService} from './interfaces'; -// Internal - Creates the in-mem backend for the HttpClient module -// AoT requires factory to be exported -export function httpClientInMemBackendServiceFactory( - dbService: InMemoryDbService, options: InMemoryBackendConfig, - xhrFactory: XhrFactory): HttpBackend { - return new HttpClientBackendService(dbService, options, xhrFactory) as HttpBackend; -} - @NgModule() export class HttpClientInMemoryWebApiModule { /** @@ -31,6 +22,8 @@ export class HttpClientInMemoryWebApiModule { * Usually imported in the root application module. * Can import in a lazy feature module too, which will shadow modules loaded earlier * + * Note: If you use the `FetchBackend`, make sure forRoot is invoked after in the providers list + * * @param dbCreator - Class that creates seed data for in-memory database. Must implement * InMemoryDbService. * @param [options] @@ -44,12 +37,10 @@ export class HttpClientInMemoryWebApiModule { return { ngModule: HttpClientInMemoryWebApiModule, providers: [ - {provide: InMemoryDbService, useClass: dbCreator}, - {provide: InMemoryBackendConfig, useValue: options}, { - provide: HttpBackend, - useFactory: httpClientInMemBackendServiceFactory, - deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory] - } + HttpClientBackendService, {provide: InMemoryDbService, useClass: dbCreator}, + {provide: InMemoryBackendConfig, useValue: options}, + {provide: HttpBackend, useExisting: HttpClientBackendService}, + {provide: PRIMARY_HTTP_BACKEND, useExisting: HttpClientBackendService} ] }; } diff --git a/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts b/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts index 6234aa513bfc2..21030dbc67c88 100644 --- a/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts +++ b/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {XhrFactory} from '@angular/common'; -import {HttpBackend} from '@angular/common/http'; +import {HttpBackend, ɵPRIMARY_HTTP_BACKEND as PRIMARY_HTTP_BACKEND} from '@angular/common/http'; import {ModuleWithProviders, NgModule, Type} from '@angular/core'; -import {httpClientInMemBackendServiceFactory} from './http-client-in-memory-web-api-module'; +import {HttpClientBackendService} from './http-client-backend-service'; import {InMemoryBackendConfig, InMemoryBackendConfigArgs, InMemoryDbService} from './interfaces'; @NgModule() @@ -23,6 +22,8 @@ export class InMemoryWebApiModule { * Usually imported in the root application module. * Can import in a lazy feature module too, which will shadow modules loaded earlier * + * Note: If you use the `FetchBackend`, make sure forRoot is invoked after in the providers list + * * @param dbCreator - Class that creates seed data for in-memory database. Must implement * InMemoryDbService. * @param [options] @@ -37,11 +38,9 @@ export class InMemoryWebApiModule { ngModule: InMemoryWebApiModule, providers: [ {provide: InMemoryDbService, useClass: dbCreator}, - {provide: InMemoryBackendConfig, useValue: options}, { - provide: HttpBackend, - useFactory: httpClientInMemBackendServiceFactory, - deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory] - } + {provide: InMemoryBackendConfig, useValue: options}, + {provide: HttpBackend, useClass: HttpClientBackendService}, + {provide: PRIMARY_HTTP_BACKEND, useExisting: HttpClientBackendService} ] }; } diff --git a/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts b/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts index d857fb969611a..0d6dab79ad73d 100644 --- a/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts +++ b/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts @@ -8,8 +8,8 @@ import 'jasmine-ajax'; -import {HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpClientModule, HttpEvent, HttpEventType, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {FetchBackend, HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpClientModule, HttpEvent, HttpEventType, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, provideHttpClient, withFetch} from '@angular/common/http'; +import {importProvidersFrom, Injectable} from '@angular/core'; import {TestBed, waitForAsync} from '@angular/core/testing'; import {HttpClientBackendService, HttpClientInMemoryWebApiModule} from 'angular-in-memory-web-api'; import {Observable, zip} from 'rxjs'; @@ -565,6 +565,34 @@ describe('HttpClient Backend Service', () => { failRequest); })); }); + + describe('when using the FetchBackend', () => { + it('should be the an InMemory Service', () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withFetch()), + importProvidersFrom( + HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay})), + {provide: HeroService, useClass: HttpClientHeroService} + ] + }); + + expect(TestBed.inject(HttpBackend)).toBeInstanceOf(HttpClientBackendService); + }); + + it('should be a FetchBackend', () => { + // In this test, providers order matters + TestBed.configureTestingModule({ + providers: [ + importProvidersFrom( + HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay})), + provideHttpClient(withFetch()), {provide: HeroService, useClass: HttpClientHeroService} + ] + }); + + expect(TestBed.inject(HttpBackend)).toBeInstanceOf(FetchBackend); + }); + }); }); From 5d73f9c9e8a8807f0170e163afa9535f170c215e Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Thu, 26 Oct 2023 18:05:48 -0700 Subject: [PATCH 29/52] docs: minor revisions to control flow guide (#52411) This commit makes some minor writing changes to the control flow guide for brevity, consistency, and active voice. PR Close #52411 --- aio/content/guide/control_flow.md | 92 ++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/aio/content/guide/control_flow.md b/aio/content/guide/control_flow.md index 0ee4bcaf9085b..67786f7aeda17 100644 --- a/aio/content/guide/control_flow.md +++ b/aio/content/guide/control_flow.md @@ -1,10 +1,12 @@ # Built-in control flow -Angular templates support *control flow blocks* that let you conditionally show, hide, and repeat elements. +Angular templates support *control flow blocks* that let you conditionally show, hide, and repeat +elements.
    -Angular built-in control flow is in [developer preview](/guide/releases#developer-preview). It is ready to try, but may change before becoming stable. +Angular built-in control flow is in [developer preview](/guide/releases#developer-preview). It is +ready to try, but may change before becoming stable.
    @@ -14,11 +16,12 @@ The `@if` block conditionally displays its content when its condition expression ```html @if (a > b) { - {{a}} is greater than {{b}} + {{a}} is greater than {{b}} } ``` -The `@if` block might have one or more associated `@else` blocks. Immediately after an `@if` block , you can optionally specify any number of `@else if` blocks and one `@else` block: +The `@if` block might have one or more associated branches. Immediately after an `@if` block, +you can optionally specify any number of `@else if` blocks and one `@else` block: ```html @if (a > b) { @@ -32,7 +35,8 @@ The `@if` block might have one or more associated `@else` blocks. Immediately af ### Referencing the conditional expression's result -The new built-in `@if` conditional supports referencing of expression results to keep a solution for common coding patterns: +You can create a reference to the result of an `@if` block's conditional expression and use that +reference inside the block's content. ```html @if (users$ | async; as users) { @@ -42,7 +46,7 @@ The new built-in `@if` conditional supports referencing of expression results to ## `@for` block - repeaters - The `@for` repeatedly renders content of a block for each item in a collection. The collection can be represented as any JavaScript [iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) but there are performance advantages of using a regular `Array`. A basic `@for` loop looks like: +The `@for` block renders its content for each item in a collection. ```html @for (item of items; track item.id) { @@ -50,24 +54,30 @@ The new built-in `@if` conditional supports referencing of expression results to } ``` +The collection can be any +JavaScript [iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols), +but standard JavaScript `Array` values offer performance advantages. + ### `track` for calculating difference of two collections -The value of the `track` expression determines a key used to associate array items with the views in the DOM. Having clear indication of the item identity allows Angular to execute a minimal set of DOM operations as items are added, removed or moved in a collection. +The `@for` block requires a `track` expression. Angular uses the value of this expression +as a unique identity for each item. This identity allows the framework to perform the minimal +set of DOM operations necessary after items are added, removed, or reordered. -Loops over immutable data without `trackBy` as one of the most common causes for performance issues across Angular applications. Because of the potential for poor performance, the `track` expression is required for the `@for` loops. When in doubt, using `track $index` is a good default. +For simple cases, you can use `track $index` as a reasonable default. ### `$index` and other contextual variables -Inside `@for` contents, several implicit variables are always available: +Inside `@for` contents, several implicit variables are always available: -| Variable | Meaning | -| -------- | ------- | +| Variable | Meaning | +|----------|-----------------------------------------------| | `$count` | Number of items in a collection iterated over | -| `$index` | Index of the current row | -| `$first` | Whether the current row is the first row | -| `$last` | Whether the current row is the last row | -| `$even` | Whether the current row index is even | -| `$odd` | Whether the current row index is odd | +| `$index` | Index of the current row | +| `$first` | Whether the current row is the first row | +| `$last` | Whether the current row is the last row | +| `$even` | Whether the current row index is even | +| `$odd` | Whether the current row index is odd | These variables are always available with these names, but can be aliased via a `let` segment: @@ -77,23 +87,25 @@ These variables are always available with these names, but can be aliased via a } ``` -The aliasing is especially useful in case of using nested `@for` blocks where contextual variable names could collide. +Aliasing is useful when nesting `@for` blocks so that you can reference these variable values in +deeper blocks. ### `empty` block -You can optionally include an `@empty` section immediately after the `@for` block content. The content of the `@empty` block displays when there are no items: +You can optionally include an `@empty` section immediately after the `@for` block content. The +content of the `@empty` block displays when there are no items: ```html @for (item of items; track item.name) { -
  • {{ item.name }}
  • +
  • {{ item.name }}
  • } @empty { -
  • There are no items.
  • +
  • There are no items.
  • } ``` ## `@switch` block - selection -The syntax for `switch` is very similar to `if`, and is inspired by the JavaScript `switch` statement: +The syntax for `switch` is similar to `if`, inspired by the JavaScript `switch` statement: ```html @switch (condition) { @@ -109,29 +121,45 @@ The syntax for `switch` is very similar to `if`, and is inspired by the JavaScri } ``` -The value of the conditional expression is compared to the case expression using the `===` operator. +The value of the conditional expression is compared to the case expression using the `===` operator. -**`@switch` does not have fallthrough**, so you do not need an equivalent to a `break` or `return` statement. +**`@switch` does not have fallthrough**, so you do not need an equivalent to a `break` or `return` +statement. -The `@default` block is optional and can be omitted. If no `@case` matches the expression and there is no `@default` block, nothing is shown. +The `@default` block is optional and can be omitted. If no `@case` matches the expression and there +is no `@default` block, nothing is shown. -## Built-in control flow and the `NgIf`, `NgSwitch` and `NgFor` structural directives +## Comparing built-in control flow to `NgIf`, `NgSwitch` and `NgFor` The `@if` block replaces `*ngIf` for expressing conditional parts of the UI. The `@switch` block replaces `ngSwitch` with major benefits: -* it does not require a container element to hold the condition expression or each conditional template; -* it supports template type-checking, including type narrowing within each branch. -The `@for` block replaces `*ngFor` for iteration, and has several differences compared to its structural directive `NgFor` predecessor: -* tracking expression (calculating keys corresponding to object identities) is mandatory but has better ergonomic (it is enough to write an expression instead of creating the `trackBy` method); -* uses a new optimized algorithm for calculating a minimal number of DOM operations to be performed in response to changes in a collection, instead of Angular’s customizable diffing implementation (`IterableDiffer`); -* has support for `@empty` blocks. +* The `@switch` block does not require a container element for the condition expression or each + conditional template. +* The `@switch` block supports template type-checking, including type narrowing within each branch. + +The `@for` block replaces `*ngFor` for iteration, and has several differences compared to its +structural directive `NgFor` predecessor: -The `track` setting replaces `NgFor`'s concept of a `trackBy` function. Because `@for` is built-in, we can provide a better experience than passing a `trackBy` function, and directly use an expression representing the key instead. Migrating from `trackBy` to `track` is possible by invoking the `trackBy` function: +* The `@for` block requires a tracking expression to uniquely identify items in the collection. + While `NgFor` requires a `trackBy` _method_, however, the `@for` block simplifies tracking by + accepting a `track` _expression_. +* You can specify content to show when the collection is empty with the `@empty` block. +* The `@for` block uses an optimized algorithm for determining a minimal number of DOM operations + necessary after a collection is modified. While `NgFor` allowed developers to provide a custom + `IterableDiffer` implementation, the `@for` block does not support custom differs. + +The `track` setting replaces `NgFor`'s concept of a `trackBy` function. Because `@for` is built-in, +we can provide a better experience than passing a `trackBy` function, and directly use an expression +representing the key instead. Migrating from `trackBy` to `track` is possible by invoking +the `trackBy` function: ```html @for (item of items; track itemId($index, item)) { {{ item.name }} } ``` + +With `NgFor`, loops over immutable data without `trackBy` are the most common cause of performance +bugs across Angular applications. From a568bc5d97d143d72ed420890aa216b5ecb1699d Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 27 Oct 2023 13:50:45 -0700 Subject: [PATCH 30/52] refactor(core): rename ViewRef to InternalViewRef and remove existing InternalViewRef (#52430) The `ViewRef` interface extends `InternalViewRef` and is already not part of the public API. There is no need for the extra `InternalViewRef` interface. This confusing setup is likely leftover from the types necessary to support both Ivy and ViewEngine. PR Close #52430 --- packages/core/src/application_ref.ts | 9 +++++---- .../core/src/change_detection/change_detector_ref.ts | 6 +++--- packages/core/src/core_private_export.ts | 1 + packages/core/src/core_render3_private_export.ts | 2 +- packages/core/src/hydration/utils.ts | 3 ++- packages/core/src/linker/template_ref.ts | 2 +- packages/core/src/linker/view_container_ref.ts | 8 ++++---- packages/core/src/linker/view_ref.ts | 6 +----- packages/core/src/render3/component_ref.ts | 4 ++-- packages/core/src/render3/di.ts | 6 +++--- packages/core/src/render3/view_ref.ts | 11 ++++------- packages/core/test/acceptance/di_spec.ts | 2 +- packages/core/test/acceptance/view_ref_spec.ts | 8 ++++---- packages/core/testing/src/component_fixture.ts | 4 ++-- 14 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/core/src/application_ref.ts b/packages/core/src/application_ref.ts index f9beaee0062f1..94fe4d44d5b02 100644 --- a/packages/core/src/application_ref.ts +++ b/packages/core/src/application_ref.ts @@ -34,7 +34,7 @@ import {COMPILER_OPTIONS, CompilerOptions} from './linker/compiler'; import {ComponentFactory, ComponentRef} from './linker/component_factory'; import {ComponentFactoryResolver} from './linker/component_factory_resolver'; import {InternalNgModuleRef, NgModuleFactory, NgModuleRef} from './linker/ng_module_factory'; -import {InternalViewRef, ViewRef} from './linker/view_ref'; +import {ViewRef} from './linker/view_ref'; import {isComponentResourceResolutionQueueEmpty, resolveComponentResources} from './metadata/resource_loading'; import {assertNgModuleType} from './render3/assert'; import {ComponentFactory as R3ComponentFactory} from './render3/component_ref'; @@ -44,6 +44,7 @@ import {setLocaleId} from './render3/i18n/i18n_locale_id'; import {setJitOptions} from './render3/jit/jit_options'; import {createNgModuleRefWithProviders, EnvironmentNgModuleRefAdapter, NgModuleFactory as R3NgModuleFactory} from './render3/ng_module_ref'; import {publishDefaultGlobalUtils as _publishDefaultGlobalUtils} from './render3/util/global_utils'; +import {InternalViewRef} from './render3/view_ref'; import {TESTABILITY} from './testability/testability'; import {isPromise} from './util/lang'; import {stringify} from './util/stringify'; @@ -825,7 +826,7 @@ export class ApplicationRef { private _destroyed = false; private _destroyListeners: Array<() => void> = []; /** @internal */ - _views: InternalViewRef[] = []; + _views: InternalViewRef[] = []; private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER); private readonly zoneIsStable = inject(ZONE_IS_STABLE_OBSERVABLE); @@ -1077,7 +1078,7 @@ export class ApplicationRef { */ attachView(viewRef: ViewRef): void { (typeof ngDevMode === 'undefined' || ngDevMode) && this.warnIfDestroyed(); - const view = (viewRef as InternalViewRef); + const view = (viewRef as InternalViewRef); this._views.push(view); view.attachToAppRef(this); } @@ -1087,7 +1088,7 @@ export class ApplicationRef { */ detachView(viewRef: ViewRef): void { (typeof ngDevMode === 'undefined' || ngDevMode) && this.warnIfDestroyed(); - const view = (viewRef as InternalViewRef); + const view = (viewRef as InternalViewRef); remove(this._views, view); view.detachFromAppRef(); } diff --git a/packages/core/src/change_detection/change_detector_ref.ts b/packages/core/src/change_detection/change_detector_ref.ts index 7b2118daf5528..6bd0102fdc4a8 100644 --- a/packages/core/src/change_detection/change_detector_ref.ts +++ b/packages/core/src/change_detection/change_detector_ref.ts @@ -13,7 +13,7 @@ import {isComponentHost} from '../render3/interfaces/type_checks'; import {DECLARATION_COMPONENT_VIEW, LView} from '../render3/interfaces/view'; import {getCurrentTNode, getLView} from '../render3/state'; import {getComponentLViewByIndex} from '../render3/util/view_utils'; -import {ViewRef} from '../render3/view_ref'; +import {InternalViewRef} from '../render3/view_ref'; /** * Base class that provides change detection functionality. @@ -145,12 +145,12 @@ function createViewRef(tNode: TNode, lView: LView, isPipe: boolean): ChangeDetec // The LView represents the location where the component is declared. // Instead we want the LView for the component View and so we need to look it up. const componentView = getComponentLViewByIndex(tNode.index, lView); // look down - return new ViewRef(componentView, componentView); + return new InternalViewRef(componentView, componentView); } else if (tNode.type & (TNodeType.AnyRNode | TNodeType.AnyContainer | TNodeType.Icu)) { // The LView represents the location where the injection is requested from. // We need to locate the containing LView (in case where the `lView` is an embedded view) const hostComponentView = lView[DECLARATION_COMPONENT_VIEW]; // look up - return new ViewRef(hostComponentView, lView); + return new InternalViewRef(hostComponentView, lView); } return null!; } diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 75df01dda7ee3..5f3c4e8e29556 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -33,6 +33,7 @@ export {clearResolutionOfComponentResourcesQueue as ɵclearResolutionOfComponent export {ReflectionCapabilities as ɵReflectionCapabilities} from './reflection/reflection_capabilities'; export {AnimationRendererType as ɵAnimationRendererType} from './render/api'; export {InjectorProfilerContext as ɵInjectorProfilerContext, setInjectorProfilerContext as ɵsetInjectorProfilerContext} from './render3/debug/injector_profiler'; +export {InternalViewRef as ɵInternalViewRef} from './render3/view_ref'; export {allowSanitizationBypassAndThrow as ɵallowSanitizationBypassAndThrow, BypassType as ɵBypassType, getSanitizationBypassType as ɵgetSanitizationBypassType, SafeHtml as ɵSafeHtml, SafeResourceUrl as ɵSafeResourceUrl, SafeScript as ɵSafeScript, SafeStyle as ɵSafeStyle, SafeUrl as ɵSafeUrl, SafeValue as ɵSafeValue, unwrapSafeValue as ɵunwrapSafeValue} from './sanitization/bypass'; export {_sanitizeHtml as ɵ_sanitizeHtml} from './sanitization/html_sanitizer'; export {_sanitizeUrl as ɵ_sanitizeUrl} from './sanitization/url_sanitizer'; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index a3a2b1ffb15ae..b40d633b279ad 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -289,7 +289,7 @@ export { publishDefaultGlobalUtils as ɵpublishDefaultGlobalUtils , publishGlobalUtil as ɵpublishGlobalUtil} from './render3/util/global_utils'; -export {ViewRef as ɵViewRef} from './render3/view_ref'; +export {InternalViewRef as ɵViewRef} from './render3/view_ref'; export { bypassSanitizationTrustHtml as ɵbypassSanitizationTrustHtml, bypassSanitizationTrustResourceUrl as ɵbypassSanitizationTrustResourceUrl, diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index 9d288ca2ccec5..92e9d6cd403fd 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -180,7 +180,8 @@ export function retrieveHydrationInfo( */ export function getLNodeForHydration(viewRef: ViewRef): LView|LContainer|null { // Reading an internal field from `ViewRef` instance. - let lView = (viewRef as any)._lView as LView; + // TODO(atscott): This should be `as InternalViewRef` but creates a circular dependency + let lView = (viewRef as any)._lView; const tView = lView[TVIEW]; // A registered ViewRef might represent an instance of an // embedded view, in which case we do not need to annotate it. diff --git a/packages/core/src/linker/template_ref.ts b/packages/core/src/linker/template_ref.ts index b64f542d1b726..3c57230ee1a78 100644 --- a/packages/core/src/linker/template_ref.ts +++ b/packages/core/src/linker/template_ref.ts @@ -12,7 +12,7 @@ import {TContainerNode, TNode, TNodeType} from '../render3/interfaces/node'; import {LView} from '../render3/interfaces/view'; import {getCurrentTNode, getLView} from '../render3/state'; import {createAndRenderEmbeddedLView} from '../render3/view_manipulation'; -import {ViewRef as R3_ViewRef} from '../render3/view_ref'; +import {InternalViewRef as R3_ViewRef} from '../render3/view_ref'; import {assertDefined} from '../util/assert'; import {createElementRef, ElementRef} from './element_ref'; diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 158a48527696d..9e9633a8f05dd 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -31,7 +31,7 @@ import {getCurrentTNode, getLView} from '../render3/state'; import {getParentInjectorIndex, getParentInjectorView, hasParentInjector} from '../render3/util/injector_utils'; import {getNativeByTNode, unwrapRNode, viewAttachedToContainer} from '../render3/util/view_utils'; import {addLViewToLContainer, shouldAddViewToDom} from '../render3/view_manipulation'; -import {ViewRef as R3ViewRef} from '../render3/view_ref'; +import {InternalViewRef} from '../render3/view_ref'; import {addToArray, removeFromArray} from '../util/array_utils'; import {assertDefined, assertEqual, assertGreaterThan, assertLessThan, throwError} from '../util/assert'; @@ -474,7 +474,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { } private insertImpl(viewRef: ViewRef, index?: number, addToDOM?: boolean): ViewRef { - const lView = (viewRef as R3ViewRef)._lView!; + const lView = (viewRef as InternalViewRef)._lView; if (ngDevMode && viewRef.destroyed) { throw new Error('Cannot insert a destroyed View in a ViewContainer!'); @@ -514,7 +514,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { addLViewToLContainer(lContainer, lView, adjustedIdx, addToDOM); - (viewRef as R3ViewRef).attachToViewContainerRef(); + (viewRef as InternalViewRef).attachToViewContainerRef(); addToArray(getOrCreateViewRefs(lContainer), adjustedIdx, viewRef); return viewRef; @@ -554,7 +554,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { const wasDetached = view && removeFromArray(getOrCreateViewRefs(this._lContainer), adjustedIdx) != null; - return wasDetached ? new R3ViewRef(view!) : null; + return wasDetached ? new InternalViewRef(view!) : null; } private _adjustIndex(index?: number, shift: number = 0) { diff --git a/packages/core/src/linker/view_ref.ts b/packages/core/src/linker/view_ref.ts index d8a44012cc921..f5a68c659a9de 100644 --- a/packages/core/src/linker/view_ref.ts +++ b/packages/core/src/linker/view_ref.ts @@ -7,6 +7,7 @@ */ import {ChangeDetectorRef} from '../change_detection/change_detector_ref'; +import {LView} from '../render3/interfaces/view'; /** * Represents an Angular [view](guide/glossary#view "Definition"). @@ -101,11 +102,6 @@ export abstract class EmbeddedViewRef extends ViewRef { abstract get rootNodes(): any[]; } -export interface InternalViewRef extends ViewRef { - detachFromAppRef(): void; - attachToAppRef(appRef: ViewRefTracker): void; -} - /** * Interface for tracking root `ViewRef`s in `ApplicationRef`. * diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 3aeca1fa8a340..e4d93d176851c 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -51,7 +51,7 @@ import {computeStaticStyling} from './styling/static_styling'; import {mergeHostAttrs, setUpAttributes} from './util/attrs_utils'; import {debugStringifyTypeForError, stringifyForError} from './util/stringify_utils'; import {getComponentLViewByIndex, getNativeByTNode, getTNode} from './util/view_utils'; -import {RootViewRef, ViewRef} from './view_ref'; +import {InternalViewRef, RootViewRef} from './view_ref'; export class ComponentFactoryResolver extends AbstractComponentFactoryResolver { /** @@ -309,7 +309,7 @@ export class ComponentFactory extends AbstractComponentFactory { */ export class ComponentRef extends AbstractComponentRef { override instance: T; - override hostView: ViewRef; + override hostView: InternalViewRef; override changeDetectorRef: ChangeDetectorRef; override componentType: Type; private previousInputValues: Map|null = null; diff --git a/packages/core/src/render3/di.ts b/packages/core/src/render3/di.ts index e118e4ff015ec..0ef22a7ae5c14 100644 --- a/packages/core/src/render3/di.ts +++ b/packages/core/src/render3/di.ts @@ -739,7 +739,7 @@ function shouldSearchParent(flags: InjectFlags, isFirstHostTNode: boolean): bool } export function getNodeInjectorLView(nodeInjector: NodeInjector): LView { - return (nodeInjector as any)._lView as LView; + return nodeInjector._lView; } export function getNodeInjectorTNode(nodeInjector: NodeInjector): TElementNode|TContainerNode| @@ -751,7 +751,7 @@ export function getNodeInjectorTNode(nodeInjector: NodeInjector): TElementNode|T export class NodeInjector implements Injector { constructor( private _tNode: TElementNode|TContainerNode|TElementContainerNode|null, - private _lView: LView) {} + public _lView: LView) {} get(token: any, notFoundValue?: any, flags?: InjectFlags|InjectOptions): any { return getOrCreateInjectable( @@ -761,7 +761,7 @@ export class NodeInjector implements Injector { /** Creates a `NodeInjector` for the current node. */ export function createNodeInjector(): Injector { - return new NodeInjector(getCurrentTNode()! as TDirectiveHostNode, getLView()) as any; + return new NodeInjector(getCurrentTNode()! as TDirectiveHostNode, getLView()); } /** diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index 6aeaedc35bb76..b1ed66afcb0b0 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -8,7 +8,7 @@ import {ChangeDetectorRef} from '../change_detection/change_detector_ref'; import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {EmbeddedViewRef, InternalViewRef, ViewRefTracker} from '../linker/view_ref'; +import {EmbeddedViewRef, ViewRefTracker} from '../linker/view_ref'; import {removeFromArray} from '../util/array_utils'; import {assertEqual} from '../util/assert'; @@ -27,7 +27,7 @@ import {storeLViewOnDestroy, updateAncestorTraversalFlagsOnAttach} from './util/ // the multiple @extends by making the annotation @implements instead interface ChangeDetectorRefInterface extends ChangeDetectorRef {} -export class ViewRef implements EmbeddedViewRef, InternalViewRef, ChangeDetectorRefInterface { +export class InternalViewRef implements EmbeddedViewRef, ChangeDetectorRefInterface { private _appRef: ViewRefTracker|null = null; private _attachedToViewContainer = false; @@ -46,8 +46,6 @@ export class ViewRef implements EmbeddedViewRef, InternalViewRef, ChangeDe * * For a "regular" ViewRef created for an embedded view, this is the `LView` for the embedded * view. - * - * @internal */ public _lView: LView, @@ -89,7 +87,7 @@ export class ViewRef implements EmbeddedViewRef, InternalViewRef, ChangeDe } else if (this._attachedToViewContainer) { const parent = this._lView[PARENT]; if (isLContainer(parent)) { - const viewRefs = parent[VIEW_REFS] as ViewRef[] | null; + const viewRefs = parent[VIEW_REFS] as InternalViewRef[] | null; const index = viewRefs ? viewRefs.indexOf(this) : -1; if (index > -1) { ngDevMode && @@ -323,8 +321,7 @@ export class ViewRef implements EmbeddedViewRef, InternalViewRef, ChangeDe } } -/** @internal */ -export class RootViewRef extends ViewRef { +export class RootViewRef extends InternalViewRef { constructor(public _view: LView) { super(_view); } diff --git a/packages/core/test/acceptance/di_spec.ts b/packages/core/test/acceptance/di_spec.ts index 8b94e5a7a9dcb..186e03e781781 100644 --- a/packages/core/test/acceptance/di_spec.ts +++ b/packages/core/test/acceptance/di_spec.ts @@ -9,7 +9,7 @@ import {CommonModule} from '@angular/common'; import {assertInInjectionContext, Attribute, ChangeDetectorRef, Component, ComponentRef, createEnvironmentInjector, createNgModule, Directive, ElementRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EventEmitter, forwardRef, Host, HostBinding, ImportedNgModuleProviders, importProvidersFrom, ImportProvidersSource, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, INJECTOR, Injector, Input, LOCALE_ID, makeEnvironmentProviders, ModuleWithProviders, NgModule, NgModuleRef, NgZone, Optional, Output, Pipe, PipeTransform, Provider, runInInjectionContext, Self, SkipSelf, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ViewRef, ɵcreateInjector as createInjector, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵINJECTOR_SCOPE, ɵInternalEnvironmentProviders as InternalEnvironmentProviders} from '@angular/core'; import {RuntimeError, RuntimeErrorCode} from '@angular/core/src/errors'; -import {ViewRef as ViewRefInternal} from '@angular/core/src/render3/view_ref'; +import {InternalViewRef as ViewRefInternal} from '@angular/core/src/render3/view_ref'; import {TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {BehaviorSubject} from 'rxjs'; diff --git a/packages/core/test/acceptance/view_ref_spec.ts b/packages/core/test/acceptance/view_ref_spec.ts index 224cb05d23d9d..3461c17a04d0f 100644 --- a/packages/core/test/acceptance/view_ref_spec.ts +++ b/packages/core/test/acceptance/view_ref_spec.ts @@ -7,7 +7,7 @@ */ import {ApplicationRef, ChangeDetectorRef, Component, ComponentRef, createComponent, ElementRef, EmbeddedViewRef, EnvironmentInjector, Injector, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; -import {InternalViewRef} from '@angular/core/src/linker/view_ref'; +import {InternalViewRef} from '@angular/core/src/render3/view_ref'; import {TestBed} from '@angular/core/testing'; @@ -25,12 +25,12 @@ describe('ViewRef', () => { create() { this.componentRef = createComponent(DynamicComponent, {environmentInjector: this.injector}); - (this.componentRef.hostView as InternalViewRef).attachToAppRef(this.appRef); + (this.componentRef.hostView as InternalViewRef).attachToAppRef(this.appRef); document.body.appendChild(this.componentRef.instance.elRef.nativeElement); } destroy() { - (this.componentRef.hostView as InternalViewRef).detachFromAppRef(); + (this.componentRef.hostView as InternalViewRef).detachFromAppRef(); } } @@ -54,7 +54,7 @@ describe('ViewRef', () => { @Component({template: ''}) class App { constructor(changeDetectorRef: ChangeDetectorRef) { - (changeDetectorRef as InternalViewRef).onDestroy(() => called = true); + (changeDetectorRef as InternalViewRef).onDestroy(() => called = true); } } diff --git a/packages/core/testing/src/component_fixture.ts b/packages/core/testing/src/component_fixture.ts index 69d4827b0bc11..50c94e94b2fae 100644 --- a/packages/core/testing/src/component_fixture.ts +++ b/packages/core/testing/src/component_fixture.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, NgZone, RendererFactory2, ɵDeferBlockDetails as DeferBlockDetails, ɵFlushableEffectRunner as FlushableEffectRunner, ɵgetDeferBlocks as getDeferBlocks} from '@angular/core'; +import {ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, NgZone, RendererFactory2, ɵDeferBlockDetails as DeferBlockDetails, ɵFlushableEffectRunner as FlushableEffectRunner, ɵgetDeferBlocks as getDeferBlocks, ɵInternalViewRef as InternalViewRef} from '@angular/core'; import {Subscription} from 'rxjs'; import {DeferBlockFixture} from './defer'; @@ -191,7 +191,7 @@ export class ComponentFixture { */ getDeferBlocks(): Promise { const deferBlocks: DeferBlockDetails[] = []; - const lView = (this.componentRef.hostView as any)['_lView']; + const lView = (this.componentRef.hostView as InternalViewRef)._lView; getDeferBlocks(lView, deferBlocks); const deferBlockFixtures = []; From a3028e23402a041527928eafdd3328ba6f08bc73 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 27 Oct 2023 15:49:14 -0700 Subject: [PATCH 31/52] refactor(core): Remove RootViewRef because it is the same as ViewRef (#52430) `RootViewRef` extends `ViewRef` and overrides 3 methods with behavior that is identical to `ViewRef`. This commit removes `RootViewRef` because it is not needed. PR Close #52430 --- packages/core/src/render3/component_ref.ts | 8 +++-- packages/core/src/render3/view_ref.ts | 34 +++---------------- .../bundle.golden_symbols.json | 12 ++----- .../animations/bundle.golden_symbols.json | 12 ++----- .../cyclic_import/bundle.golden_symbols.json | 12 ++----- .../bundling/defer/bundle.golden_symbols.json | 12 ++----- .../forms_reactive/bundle.golden_symbols.json | 12 ++----- .../bundle.golden_symbols.json | 12 ++----- .../hello_world/bundle.golden_symbols.json | 12 ++----- .../hydration/bundle.golden_symbols.json | 12 ++----- .../router/bundle.golden_symbols.json | 12 ++----- .../bundle.golden_symbols.json | 12 ++----- .../bundling/todo/bundle.golden_symbols.json | 12 ++----- 13 files changed, 44 insertions(+), 130 deletions(-) diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index e4d93d176851c..75a473ee87e5c 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -51,7 +51,7 @@ import {computeStaticStyling} from './styling/static_styling'; import {mergeHostAttrs, setUpAttributes} from './util/attrs_utils'; import {debugStringifyTypeForError, stringifyForError} from './util/stringify_utils'; import {getComponentLViewByIndex, getNativeByTNode, getTNode} from './util/view_utils'; -import {InternalViewRef, RootViewRef} from './view_ref'; +import {InternalViewRef} from './view_ref'; export class ComponentFactoryResolver extends AbstractComponentFactoryResolver { /** @@ -319,7 +319,11 @@ export class ComponentRef extends AbstractComponentRef { private _tNode: TElementNode|TContainerNode|TElementContainerNode) { super(); this.instance = instance; - this.hostView = this.changeDetectorRef = new RootViewRef(_rootLView); + this.hostView = this.changeDetectorRef = new InternalViewRef( + _rootLView, + undefined, /* _cdRefInjectingView */ + false, /* notifyErrorHandler */ + ); this.componentType = componentType; } diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index b1ed66afcb0b0..3bc67809329a5 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -55,7 +55,7 @@ export class InternalViewRef implements EmbeddedViewRef, ChangeDetectorRef * * This may be different from `_lView` if the `_cdRefInjectingView` is an embedded view. */ - private _cdRefInjectingView?: LView) {} + private _cdRefInjectingView?: LView, private readonly notifyErrorHandler = true) {} get context(): T { return this._lView[CONTEXT] as unknown as T; @@ -282,7 +282,8 @@ export class InternalViewRef implements EmbeddedViewRef, ChangeDetectorRef * See {@link ChangeDetectorRef#detach} for more information. */ detectChanges(): void { - detectChangesInternal(this._lView[TVIEW], this._lView, this.context as unknown as {}); + detectChangesInternal( + this._lView[TVIEW], this._lView, this.context as unknown as {}, this.notifyErrorHandler); } /** @@ -293,7 +294,8 @@ export class InternalViewRef implements EmbeddedViewRef, ChangeDetectorRef */ checkNoChanges(): void { if (ngDevMode) { - checkNoChangesInternal(this._lView[TVIEW], this._lView, this.context as unknown as {}); + checkNoChangesInternal( + this._lView[TVIEW], this._lView, this.context as unknown as {}, this.notifyErrorHandler); } } @@ -320,29 +322,3 @@ export class InternalViewRef implements EmbeddedViewRef, ChangeDetectorRef this._appRef = appRef; } } - -export class RootViewRef extends InternalViewRef { - constructor(public _view: LView) { - super(_view); - } - - override detectChanges(): void { - const lView = this._view; - const tView = lView[TVIEW]; - const context = lView[CONTEXT]; - detectChangesInternal(tView, lView, context, false); - } - - override checkNoChanges(): void { - if (ngDevMode) { - const lView = this._view; - const tView = lView[TVIEW]; - const context = lView[CONTEXT]; - checkNoChangesInternal(tView, lView, context, false); - } - } - - override get context(): T { - return null!; - } -} diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index e7f432226bb03..ec3e3d9d53b78 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -269,6 +269,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "LEAVE_TOKEN_REGEX" }, @@ -443,9 +446,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -548,9 +548,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "WebAnimationsDriver" }, @@ -824,9 +821,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index 09b6902a01a11..b0892a3e1b440 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -290,6 +290,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "KeyEventsPlugin" }, @@ -485,9 +488,6 @@ { "name": "RootComponent" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -602,9 +602,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "WebAnimationsDriver" }, @@ -887,9 +884,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 40a252cfb8d6a..e6d577ba89eca 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -194,6 +194,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "KeyEventsPlugin" }, @@ -368,9 +371,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -458,9 +458,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -668,9 +665,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 686a3be5804ef..4d62af2c684f9 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -227,6 +227,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "KeyEventsPlugin" }, @@ -419,9 +422,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -509,9 +509,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -758,9 +755,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 7ede144dc40ff..c7f78e002cb84 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -272,6 +272,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "IterableChangeRecord_" }, @@ -506,9 +509,6 @@ { "name": "RootComponent" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -614,9 +614,6 @@ { "name": "ViewEngineTemplateRef" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -911,9 +908,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 52c2528d3d1f1..c9bbbf99c7009 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -257,6 +257,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "IterableChangeRecord_" }, @@ -494,9 +497,6 @@ { "name": "RootComponent" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -605,9 +605,6 @@ { "name": "ViewEngineTemplateRef" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -881,9 +878,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 1581f1b704a7a..9d33864b68e5e 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -137,6 +137,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "LOCALE_ID2" }, @@ -281,9 +284,6 @@ { "name": "RendererFactory2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -350,9 +350,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -527,9 +524,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 0cb24e7aab597..6356ad7278536 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -245,6 +245,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "LOCALE_ID2" }, @@ -428,9 +431,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -539,9 +539,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -752,9 +749,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index cbda420a4ae56..85c1b191b432e 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -323,6 +323,9 @@ { "name": "InnerSubscriber" }, + { + "name": "InternalViewRef" + }, { "name": "ItemComponent" }, @@ -629,9 +632,6 @@ { "name": "ResolveStart" }, - { - "name": "RootViewRef" - }, { "name": "RouteConfigLoadEnd" }, @@ -845,9 +845,6 @@ { "name": "ViewEngineTemplateRef" }, - { - "name": "ViewRef" - }, { "name": "XSS_SECURITY_URL" }, @@ -1181,9 +1178,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 53f4a0f630ea9..ae12373e50277 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -179,6 +179,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "LOCALE_ID2" }, @@ -332,9 +335,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -410,9 +410,6 @@ { "name": "ViewEncapsulation" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -599,9 +596,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 6e88fdcd4208a..adc3abfd13db1 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -197,6 +197,9 @@ { "name": "Injector" }, + { + "name": "InternalViewRef" + }, { "name": "IterableChangeRecord_" }, @@ -395,9 +398,6 @@ { "name": "RendererStyleFlags2" }, - { - "name": "RootViewRef" - }, { "name": "RuntimeError" }, @@ -530,9 +530,6 @@ { "name": "ViewEngineTemplateRef" }, - { - "name": "ViewRef" - }, { "name": "ZONE_IS_STABLE_OBSERVABLE" }, @@ -797,9 +794,6 @@ { "name": "detectChangesInViewIfAttached" }, - { - "name": "detectChangesInternal" - }, { "name": "diPublicInInjector" }, From b5ef68ff0fcf3d31fe3cbe60724d836f685c5a70 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 30 Oct 2023 09:47:50 +0100 Subject: [PATCH 32/52] fix(compiler): compilation error when for loop block expression contains new line (#52447) Fixes that the regex which captures the expression of a `@for` loop block wasn't accounting for line breaks. Fixes #52446. PR Close #52447 --- packages/compiler/src/render3/r3_control_flow.ts | 2 +- .../test/render3/r3_template_transform_spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 81aa3f3f693c8..5bacb2c5fcac0 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -14,7 +14,7 @@ import {BindingParser} from '../template_parser/binding_parser'; import * as t from './r3_ast'; /** Pattern for the expression in a for loop block. */ -const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+(.*)/; +const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+([\S\s]*)/; /** Pattern for the tracking expression in a for loop block. */ const FOR_LOOP_TRACK_PATTERN = /^track\s+([\S\s]*)/; diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 5472d850462c6..224119a151d48 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -1594,6 +1594,21 @@ describe('R3 template transform', () => { `).toEqual(expectedResult); }); + it('should parse for loop block expression containing new lines', () => { + expectFromHtml(` + @for (item of [ + { id: 1 }, + { id: 2 } + ]; track item.id) { + {{ item }} + } + `).toEqual([ + ['ForLoopBlock', '[{id: 1}, {id: 2}]', 'item.id'], + ['Variable', 'item', '$implicit'], + ['BoundText', ' {{ item }} '], + ]); + }); + describe('validations', () => { it('should report if for loop does not have an expression', () => { expect(() => parse(`@for {hello}`)).toThrowError(/@for loop does not have an expression/); From 5f528bfb41a113e79d30e56b696e1fb06e7391d3 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 30 Oct 2023 09:58:52 +0100 Subject: [PATCH 33/52] fix(compiler): allow comments between switch cases (#52449) Fixes that the template parser was throwing an error if a comment is used directly inside an `@switch` block. Fixes #52421. PR Close #52449 --- .../compiler/src/render3/r3_control_flow.ts | 6 ++++-- .../test/render3/r3_template_transform_spec.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 5bacb2c5fcac0..09228c1b451c3 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -338,8 +338,10 @@ function validateSwitchBlock(ast: html.Block): ParseError[] { } for (const node of ast.children) { - // Skip over empty text nodes inside the switch block since they can be used for formatting. - if (node instanceof html.Text && node.value.trim().length === 0) { + // Skip over comments and empty text nodes inside the switch block. + // Empty text nodes can be used for formatting while comments don't affect the runtime. + if (node instanceof html.Comment || + (node instanceof html.Text && node.value.trim().length === 0)) { continue; } diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 224119a151d48..0c17a70cf112c 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -1364,6 +1364,24 @@ describe('R3 template transform', () => { ]); }); + it('should parse a switch block containing comments', () => { + expectFromHtml(` + @switch (cond.kind) { + + @case (x) { X case } + + + @default { No case matched } + } + `).toEqual([ + ['SwitchBlock', 'cond.kind'], + ['SwitchBlockCase', 'x'], + ['Text', ' X case '], + ['SwitchBlockCase', null], + ['Text', ' No case matched '], + ]); + }); + describe('validations', () => { it('should report syntax error in switch expression', () => { expect(() => parse(` From 245a6b3f23a18e09b3831d5ee60e4a5889745329 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 30 Oct 2023 10:15:50 +0100 Subject: [PATCH 34/52] refactor(compiler): account for comments when resolving implicit deferred triggers (#52449) Adds some logic to skip over comments when resolving implicit `@defer` block triggers. This currently isn't a problem since we don't capture comments by default, but it may come up if we start capturing comments. PR Close #52449 --- .../compiler/src/render3/view/t2_binder.ts | 35 +++++++++++++------ .../test/render3/view/binding_spec.ts | 19 ++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index ad701f001891c..1355b49bcb187 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -8,7 +8,7 @@ import {AST, BindingPipe, ImplicitReceiver, PropertyRead, PropertyWrite, RecursiveAstVisitor, SafePropertyRead} from '../../expression_parser/ast'; import {SelectorMatcher} from '../../selector'; -import {BoundAttribute, BoundEvent, BoundText, Content, DeferredBlock, DeferredBlockError, DeferredBlockLoading, DeferredBlockPlaceholder, DeferredTrigger, Element, ForLoopBlock, ForLoopBlockEmpty, HoverDeferredTrigger, Icu, IfBlock, IfBlockBranch, InteractionDeferredTrigger, Node, Reference, SwitchBlock, SwitchBlockCase, Template, Text, TextAttribute, UnknownBlock, Variable, ViewportDeferredTrigger, Visitor} from '../r3_ast'; +import {BoundAttribute, BoundEvent, BoundText, Comment, Content, DeferredBlock, DeferredBlockError, DeferredBlockLoading, DeferredBlockPlaceholder, DeferredTrigger, Element, ForLoopBlock, ForLoopBlockEmpty, HoverDeferredTrigger, Icu, IfBlock, IfBlockBranch, InteractionDeferredTrigger, Node, Reference, SwitchBlock, SwitchBlockCase, Template, Text, TextAttribute, UnknownBlock, Variable, ViewportDeferredTrigger, Visitor} from '../r3_ast'; import {BoundTarget, DirectiveMeta, ReferenceTarget, ScopedNode, Target, TargetBinder} from './t2_api'; import {createCssSelector} from './template'; @@ -798,14 +798,29 @@ export class R3BoundTarget implements BoundTar const name = trigger.reference; if (name === null) { - const children = block.placeholder ? block.placeholder.children : null; - - // If the trigger doesn't have a reference, it is inferred as the root element node of the - // placeholder, if it only has one root node. Otherwise it's ambiguous so we don't - // attempt to resolve further. - return children !== null && children.length === 1 && children[0] instanceof Element ? - children[0] : - null; + let trigger: Element|null = null; + + if (block.placeholder !== null) { + for (const child of block.placeholder.children) { + // Skip over comment nodes. Currently by default the template parser doesn't capture + // comments, but we have a safeguard here just in case since it can be enabled. + if (child instanceof Comment) { + continue; + } + + // We can only infer the trigger if there's one root element node. Any other + // nodes at the root make it so that we can't infer the trigger anymore. + if (trigger !== null) { + return null; + } + + if (child instanceof Element) { + trigger = child; + } + } + } + + return trigger; } const outsideRef = this.findEntityInScope(block, name); @@ -821,7 +836,7 @@ export class R3BoundTarget implements BoundTar } // If the trigger couldn't be found in the main block, check the - // placeholder block which is shown before the main block has loaded. + // placeholder block which is shown before the main block has loaded. if (block.placeholder !== null) { const refInPlaceholder = this.findEntityInScope(block.placeholder, name); const targetInPlaceholder = diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index 425e9f939ce71..eb682c3fd2b60 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -535,6 +535,25 @@ describe('t2 binding', () => { expect(triggerEl?.name).toBe('button'); }); + it('should identify an implicit trigger inside the placeholder block with comments', () => { + const template = parseTemplate( + ` + @defer (on viewport) { + main + } @placeholder { + + + + } + `, + ''); + const binder = new R3TargetBinder(makeSelectorMatcher()); + const bound = binder.bind({template: template.nodes}); + const block = Array.from(bound.getDeferBlocks())[0]; + const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!); + expect(triggerEl?.name).toBe('button'); + }); + it('should not identify an implicit trigger if the placeholder has multiple root nodes', () => { const template = parseTemplate( ` From 75725fb69f270c659a6d836aef3dbbb2710ef23a Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 27 Oct 2023 14:09:01 -0700 Subject: [PATCH 35/52] docs(core): Deprecate `ChangeDetectorRef.checkNoChanges` (#52431) The `checkNoChanges` method does not belong in the API of production interface. `checkNoChanges` is limited to testing and should not be used in any application code. Test code should use `ComponentFixture` instead of `ChangeDetectorRef`. Additionally, it is not desirable to have the `checkNoChanges` API available in a context where `detectChanges` is not run first. DEPRECATED: `ChangeDetectorRef.checkNoChanges` is deprecated. Test code should use `ComponentFixture` instead of `ChangeDetectorRef`. Application code should not call `ChangeDetectorRef.checkNoChanges` directly. PR Close #52431 --- goldens/public-api/core/index.md | 1 + packages/core/src/change_detection/change_detector_ref.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index 1144346016742..e0c06b1c2ee6d 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -168,6 +168,7 @@ export enum ChangeDetectionStrategy { // @public export abstract class ChangeDetectorRef { + // @deprecated abstract checkNoChanges(): void; abstract detach(): void; abstract detectChanges(): void; diff --git a/packages/core/src/change_detection/change_detector_ref.ts b/packages/core/src/change_detection/change_detector_ref.ts index 6bd0102fdc4a8..4dc711d5ba58f 100644 --- a/packages/core/src/change_detection/change_detector_ref.ts +++ b/packages/core/src/change_detection/change_detector_ref.ts @@ -104,6 +104,10 @@ export abstract class ChangeDetectorRef { * * Use in development mode to verify that running change detection doesn't introduce * other changes. Calling it in production mode is a noop. + * + * @deprecated This is a test-only API that does not have a place in production interface. + * `checkNoChanges` is already part of an `ApplicationRef` tick when the app is running in dev + * mode. For more granular `checkNoChanges` validation, use `ComponentFixture`. */ abstract checkNoChanges(): void; From d7c4f569f46ef42d6ebb33704cda0a7b314176d2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 26 Oct 2023 10:29:38 +0200 Subject: [PATCH 36/52] fix(compiler): allow decimals in defer block time values (#52433) Fixes that our regex for parsing time values in defer blocks didn't allow for decimals. This isn't relevant for times in milliseconds, but it can be convenient to write something like `on timer(1.5s)`. PR Close #52433 --- .../src/render3/r3_deferred_triggers.ts | 4 ++-- .../render3/r3_template_transform_spec.ts | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/render3/r3_deferred_triggers.ts b/packages/compiler/src/render3/r3_deferred_triggers.ts index fb28325ccb8be..8917e4128802a 100644 --- a/packages/compiler/src/render3/r3_deferred_triggers.ts +++ b/packages/compiler/src/render3/r3_deferred_triggers.ts @@ -15,7 +15,7 @@ import {BindingParser} from '../template_parser/binding_parser'; import * as t from './r3_ast'; /** Pattern for a timing value in a trigger. */ -const TIME_PATTERN = /^\d+(ms|s)?$/; +const TIME_PATTERN = /^\d+\.?\d*(ms|s)?$/; /** Pattern for a separator between keywords in a trigger expression. */ const SEPARATOR_PATTERN = /^\s$/; @@ -440,5 +440,5 @@ export function parseDeferredTime(value: string): number|null { } const [time, units] = match; - return parseInt(time) * (units === 's' ? 1000 : 1); + return parseFloat(time) * (units === 's' ? 1000 : 1); } diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 0c17a70cf112c..ce7265a49e6d9 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -807,7 +807,7 @@ describe('R3 template transform', () => { ]); }); - it('should parse a deferred block with a timeout set in seconds', () => { + it('should parse a deferred block with a timer set in seconds', () => { expectFromHtml('@defer (on timer(10s)){hello}').toEqual([ ['DeferredBlock'], ['TimerDeferredTrigger', 10000], @@ -815,7 +815,15 @@ describe('R3 template transform', () => { ]); }); - it('should parse a deferred block with a timeout that has no units', () => { + it('should parse a deferred block with a timer with a decimal point', () => { + expectFromHtml('@defer (on timer(1.5s)){hello}').toEqual([ + ['DeferredBlock'], + ['TimerDeferredTrigger', 1500], + ['Text', 'hello'], + ]); + }); + + it('should parse a deferred block with a timer that has no units', () => { expectFromHtml('@defer (on timer(100)){hello}').toEqual([ ['DeferredBlock'], ['TimerDeferredTrigger', 100], @@ -883,12 +891,12 @@ describe('R3 template transform', () => { it('should parse a loading block with parameters', () => { expectFromHtml( '@defer{}' + - '@loading (after 100ms; minimum 1s){Loading...}') + '@loading (after 100ms; minimum 1.5s){Loading...}') .toEqual([ ['DeferredBlock'], ['Element', 'calendar-cmp'], ['BoundAttribute', 0, 'date', 'current'], - ['DeferredBlockLoading', 'after 100ms', 'minimum 1000ms'], + ['DeferredBlockLoading', 'after 100ms', 'minimum 1500ms'], ['Text', 'Loading...'], ]); }); @@ -896,12 +904,12 @@ describe('R3 template transform', () => { it('should parse a placeholder block with parameters', () => { expectFromHtml( '@defer {}' + - '@placeholder (minimum 1s){Placeholder...}') + '@placeholder (minimum 1.5s){Placeholder...}') .toEqual([ ['DeferredBlock'], ['Element', 'calendar-cmp'], ['BoundAttribute', 0, 'date', 'current'], - ['DeferredBlockPlaceholder', 'minimum 1000ms'], + ['DeferredBlockPlaceholder', 'minimum 1500ms'], ['Text', 'Placeholder...'], ]); }); From 6c274ec2ed75853b0d4b46309fab165ee594694f Mon Sep 17 00:00:00 2001 From: Swami Date: Sat, 28 Oct 2023 16:58:38 +0530 Subject: [PATCH 37/52] docs(core): remove documentation of mutate function (#52435) PR Close #52435 --- aio/content/guide/signals.md | 13 ------------- packages/core/primitives/signals/README.md | 12 +----------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/aio/content/guide/signals.md b/aio/content/guide/signals.md index 67fcfd1b8c853..c74ea1a43a4c2 100644 --- a/aio/content/guide/signals.md +++ b/aio/content/guide/signals.md @@ -35,17 +35,6 @@ or use the `.update()` operation to compute a new value from the previous one: count.update(value => value + 1); ``` -When working with signals that contain objects, it's sometimes useful to mutate that object directly. For example, if the object is an array, you may want to push a new value without replacing the array entirely. To make an internal change like this, use the `.mutate` method: - -```ts -const todos = signal([{title: 'Learn signals', done: false}]); - -todos.mutate(value => { - // Change the first TODO in the array to 'done: true' without replacing it. - value[0].done = true; -}); -``` - Writable signals have the type `WritableSignal`. ### Computed signals @@ -203,8 +192,6 @@ data.set(['test']); Equality functions can be provided to both writable and computed signals. -For writable signals, `.mutate()` does not check for equality because it mutates the current value without producing a new reference. - ### Reading without tracking dependencies Rarely, you may want to execute code which may read signals in a reactive function such as `computed` or `effect` _without_ creating a dependency. diff --git a/packages/core/primitives/signals/README.md b/packages/core/primitives/signals/README.md index 992d2f79c323f..777001d493665 100644 --- a/packages/core/primitives/signals/README.md +++ b/packages/core/primitives/signals/README.md @@ -16,7 +16,7 @@ This context and getter function mechanism allows for signal dependencies of a c ### Writable signals: `signal()` -The `createSignal()` function produces a specific type of signal that tracks a stored value. In addition to providing a getter function, these signals can be wired up with additional APIs for changing the value of the signal (along with notifying any dependents of the change). These include the `.set` operation for replacing the signal value, `.update` for deriving a new value, and `.mutate` for performing internal mutation of the current value. In Angular, these are exposed as functions on the signal getter itself. For example: +The `createSignal()` function produces a specific type of signal that tracks a stored value. In addition to providing a getter function, these signals can be wired up with additional APIs for changing the value of the signal (along with notifying any dependents of the change). These include the `.set` operation for replacing the signal value, and `.update` for deriving a new value. In Angular, these are exposed as functions on the signal getter itself. For example: ```typescript const counter = signal(0); @@ -25,16 +25,6 @@ counter.set(2); counter.update(count => count + 1); ``` -The signal value can be also updated in-place, using the dedicated `.mutate` method: - -```typescript -const todoList = signal([]); - -todoList.mutate(list => { - list.push({title: 'One more task', completed: false}); -}); -``` - #### Equality The signal creation function one can, optionally, specify an equality comparator function. The comparator is used to decide whether the new supplied value is the same, or different, as compared to the current signal’s value. From 0c7accf92dd552abe90827f986a5e3c4ae08d85d Mon Sep 17 00:00:00 2001 From: JoostK Date: Sat, 28 Oct 2023 21:59:19 +0200 Subject: [PATCH 38/52] fix(compiler-cli): properly emit literal types in input coercion function arguments (#52437) This commit fixes an issue where using literal types in the arguments of an input coercion function could result in emitting invalid output, due to an assumption that TypeScript makes when emitting literal types. Specifically, it takes the literal's text from its containing source file, but this breaks when the literal type node has been transplanted into a different source file. This issue has surfaced in the type-check code generator and is already being addressed there, so this commit moves the relevant `TypeEmitter` class from the `typecheck` module to the `translator` module, such that it can be reused for emitting types in the type translator. Fixes #51672 PR Close #52437 --- .../src/ngtsc/translator/index.ts | 1 + .../src/type_emitter.ts | 0 .../ngtsc/translator/src/type_translator.ts | 70 ++++--------------- .../typecheck/src/type_parameter_emitter.ts | 4 +- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 27 +++++++ 5 files changed, 44 insertions(+), 58 deletions(-) rename packages/compiler-cli/src/ngtsc/{typecheck => translator}/src/type_emitter.ts (100%) diff --git a/packages/compiler-cli/src/ngtsc/translator/index.ts b/packages/compiler-cli/src/ngtsc/translator/index.ts index 7fa09856f8f52..3995b25f86beb 100644 --- a/packages/compiler-cli/src/ngtsc/translator/index.ts +++ b/packages/compiler-cli/src/ngtsc/translator/index.ts @@ -11,6 +11,7 @@ export {ImportGenerator, NamedImport} from './src/api/import_generator'; export {Context} from './src/context'; export {Import, ImportManager} from './src/import_manager'; export {ExpressionTranslatorVisitor, RecordWrappedNodeFn, TranslatorOptions} from './src/translator'; +export {canEmitType, TypeEmitter, TypeReferenceTranslator} from './src/type_emitter'; export {translateType} from './src/type_translator'; export {attachComments, createTemplateMiddle, createTemplateTail, TypeScriptAstFactory} from './src/typescript_ast_factory'; export {translateExpression, translateStatement} from './src/typescript_translator'; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_emitter.ts b/packages/compiler-cli/src/ngtsc/translator/src/type_emitter.ts similarity index 100% rename from packages/compiler-cli/src/ngtsc/typecheck/src/type_emitter.ts rename to packages/compiler-cli/src/ngtsc/translator/src/type_emitter.ts diff --git a/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts index 4a3a0d3f2d989..885fc57b23f93 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts @@ -14,6 +14,7 @@ import {ReflectionHost} from '../../reflection'; import {Context} from './context'; import {ImportManager} from './import_manager'; +import {TypeEmitter} from './type_emitter'; export function translateType( @@ -79,12 +80,13 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { return ts.factory.createTypeLiteralNode([indexSignature]); } - visitTransplantedType(ast: o.TransplantedType, context: any) { + visitTransplantedType(ast: o.TransplantedType, context: Context) { if (!ts.isTypeNode(ast.type)) { throw new Error(`A TransplantedType must wrap a TypeNode`); } - return this.translateTransplantedTypeNode(ast.type, context); + const emitter = new TypeEmitter(typeRef => this.translateTypeReference(typeRef, context)); + return emitter.emitType(ast.type); } visitReadVarExpr(ast: o.ReadVarExpr, context: Context): ts.TypeQueryNode { @@ -253,17 +255,13 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { return typeNode; } - /** - * Translates a type reference node so that all of its references - * are imported into the context file. - */ - private translateTransplantedTypeReferenceNode( - node: ts.TypeReferenceNode&{typeName: ts.Identifier}, context: any): ts.TypeReferenceNode { - const declaration = this.reflector.getDeclarationOfIdentifier(node.typeName); - + private translateTypeReference(type: ts.TypeReferenceNode, context: Context): ts.TypeReferenceNode + |null { + const target = ts.isIdentifier(type.typeName) ? type.typeName : type.typeName.right; + const declaration = this.reflector.getDeclarationOfIdentifier(target); if (declaration === null) { throw new Error( - `Unable to statically determine the declaration file of type node ${node.typeName.text}`); + `Unable to statically determine the declaration file of type node ${target.text}`); } const emittedType = this.refEmitter.emit( @@ -271,52 +269,14 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { ImportFlags.NoAliasing | ImportFlags.AllowTypeImports | ImportFlags.AllowRelativeDtsImports); - assertSuccessfulReferenceEmit(emittedType, node, 'type'); - - const result = emittedType.expression.visitExpression(this, context); + assertSuccessfulReferenceEmit(emittedType, target, 'type'); - if (!ts.isTypeReferenceNode(result)) { - throw new Error(`Expected TypeReferenceNode when referencing the type for ${ - node.typeName.text}, but received ${ts.SyntaxKind[result.kind]}`); - } + const typeNode = this.translateExpression(emittedType.expression, context); - // If the original node doesn't have any generic parameters we return the results. - if (node.typeArguments === undefined || node.typeArguments.length === 0) { - return result; + if (!ts.isTypeReferenceNode(typeNode)) { + throw new Error( + `Expected TypeReferenceNode for emitted reference, got ${ts.SyntaxKind[typeNode.kind]}.`); } - - // If there are any generics, we have to reflect them as well. - const translatedArgs = - node.typeArguments.map(arg => this.translateTransplantedTypeNode(arg, context)); - - return ts.factory.updateTypeReferenceNode( - result, result.typeName, ts.factory.createNodeArray(translatedArgs)); - } - - /** - * Translates a type node so that all of the type references it - * contains are imported and can be referenced in the context file. - */ - private translateTransplantedTypeNode(rootNode: ts.TypeNode, context: any): ts.TypeNode { - const factory: ts.TransformerFactory = transformContext => root => { - const walk = (node: ts.Node): ts.Node => { - if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { - const translated = - this.translateTransplantedTypeReferenceNode(node as ts.TypeReferenceNode & { - typeName: ts.Identifier; - }, context); - - if (translated !== node) { - return translated; - } - } - - return ts.visitEachChild(node, walk, transformContext); - }; - - return ts.visitNode(root, walk); - }; - - return ts.transform(rootNode, [factory]).transformed[0] as ts.TypeNode; + return typeNode; } } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_parameter_emitter.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_parameter_emitter.ts index 066afb37d9018..817eb8fb3bc17 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_parameter_emitter.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_parameter_emitter.ts @@ -9,9 +9,7 @@ import ts from 'typescript'; import {OwningModule, Reference} from '../../imports'; import {DeclarationNode, ReflectionHost} from '../../reflection'; - -import {canEmitType, TypeEmitter} from './type_emitter'; - +import {canEmitType, TypeEmitter} from '../../translator'; /** * See `TypeEmitter` for more information on the emitting process. diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index bca4e1ddf5e63..77b290aa5f365 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8748,6 +8748,33 @@ function allTests(os: string) { expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;'); }); + it('should compile an input referencing an imported function with literal types', () => { + env.write('/transforms.ts', ` + export function toBoolean(value: boolean | '' | 'true' | 'false'): boolean { + return !!value; + } + `); + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {toBoolean} from './transforms'; + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toBoolean}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toBoolean] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents) + .toContain(`static ngAcceptInputType_value: boolean | "" | "true" | "false";`); + }); + it('should compile a directive input with a transform function with a `this` typing', () => { env.write('/test.ts', ` import {Directive, Input} from '@angular/core'; From 581eff4ab1c5f567692a14551e5059bca5a4d6b8 Mon Sep 17 00:00:00 2001 From: JoostK Date: Sun, 29 Oct 2023 00:53:50 +0200 Subject: [PATCH 39/52] fix(compiler-cli): use originally used module specifier for transform functions (#52437) Prior to this change, the transform function would be referenced with a potentially relative import into an external declaration file. Such imports are not portable and should not be created in this context. This commit addresses the issue by threading though the originally used module specifier by means of the `Reference` type. Fixes #52324 PR Close #52437 --- goldens/public-api/common/index.md | 2 +- .../ngtsc/annotations/directive/src/shared.ts | 8 +++-- .../src/ngtsc/metadata/src/api.ts | 2 +- .../ngtsc/translator/src/type_translator.ts | 32 +++++++++++++------ .../src/ngtsc/typecheck/src/environment.ts | 4 +-- .../ngtsc/typecheck/src/type_check_block.ts | 7 ++-- .../ngtsc/typecheck/src/type_constructor.ts | 2 +- .../typecheck/test/type_check_block_spec.ts | 5 +-- .../typecheck/test/type_constructor_spec.ts | 4 +-- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 4 +-- 10 files changed, 45 insertions(+), 25 deletions(-) diff --git a/goldens/public-api/common/index.md b/goldens/public-api/common/index.md index 6b18b132b9211..e7a32f13bf87b 100644 --- a/goldens/public-api/common/index.md +++ b/goldens/public-api/common/index.md @@ -615,7 +615,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { // (undocumented) static ngAcceptInputType_height: unknown; // (undocumented) - static ngAcceptInputType_ngSrc: string | i1_2.SafeValue; + static ngAcceptInputType_ngSrc: string | i0.ɵSafeValue; // (undocumented) static ngAcceptInputType_priority: unknown; // (undocumented) diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts index 5b0988ad2ff27..cb7dc6ae103d1 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts @@ -779,7 +779,10 @@ function parseInputTransformFunction( // Treat functions with no arguments as `unknown` since returning // the same value from the transform function is valid. if (!firstParam) { - return {node, type: ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)}; + return { + node, + type: new Reference(ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)) + }; } // This should be caught by `noImplicitAny` already, but null check it just in case. @@ -795,7 +798,8 @@ function parseInputTransformFunction( assertEmittableInputType(firstParam.type, clazz.getSourceFile(), reflector, refEmitter); - return {node, type: firstParam.type}; + const viaModule = value instanceof Reference ? value.bestGuessOwningModule : null; + return {node, type: new Reference(firstParam.type, viaModule)}; } /** diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 21e845a2ccd3d..968f464a99a85 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -140,7 +140,7 @@ export type InputMapping = InputOrOutput&{ /** Metadata for an input's transform function. */ export interface InputTransform { node: ts.Node; - type: ts.TypeNode; + type: Reference; } /** diff --git a/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts index 885fc57b23f93..c0bb3e8e84bfe 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/type_translator.ts @@ -9,7 +9,7 @@ import * as o from '@angular/compiler'; import ts from 'typescript'; -import {assertSuccessfulReferenceEmit, ImportFlags, Reference, ReferenceEmitter} from '../../imports'; +import {assertSuccessfulReferenceEmit, ImportFlags, OwningModule, Reference, ReferenceEmitter} from '../../imports'; import {ReflectionHost} from '../../reflection'; import {Context} from './context'; @@ -80,13 +80,17 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { return ts.factory.createTypeLiteralNode([indexSignature]); } - visitTransplantedType(ast: o.TransplantedType, context: Context) { - if (!ts.isTypeNode(ast.type)) { + visitTransplantedType(ast: o.TransplantedType, context: Context) { + const node = ast.type instanceof Reference ? ast.type.node : ast.type; + if (!ts.isTypeNode(node)) { throw new Error(`A TransplantedType must wrap a TypeNode`); } - const emitter = new TypeEmitter(typeRef => this.translateTypeReference(typeRef, context)); - return emitter.emitType(ast.type); + const viaModule = ast.type instanceof Reference ? ast.type.bestGuessOwningModule : null; + + const emitter = + new TypeEmitter(typeRef => this.translateTypeReference(typeRef, context, viaModule)); + return emitter.emitType(node); } visitReadVarExpr(ast: o.ReadVarExpr, context: Context): ts.TypeQueryNode { @@ -255,8 +259,9 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { return typeNode; } - private translateTypeReference(type: ts.TypeReferenceNode, context: Context): ts.TypeReferenceNode - |null { + private translateTypeReference( + type: ts.TypeReferenceNode, context: Context, + viaModule: OwningModule|null): ts.TypeReferenceNode|null { const target = ts.isIdentifier(type.typeName) ? type.typeName : type.typeName.right; const declaration = this.reflector.getDeclarationOfIdentifier(target); if (declaration === null) { @@ -264,10 +269,17 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor { `Unable to statically determine the declaration file of type node ${target.text}`); } + let owningModule = viaModule; + if (declaration.viaModule !== null) { + owningModule = { + specifier: declaration.viaModule, + resolutionContext: type.getSourceFile().fileName, + }; + } + + const reference = new Reference(declaration.node, owningModule); const emittedType = this.refEmitter.emit( - new Reference(declaration.node), this.contextFile, - ImportFlags.NoAliasing | ImportFlags.AllowTypeImports | - ImportFlags.AllowRelativeDtsImports); + reference, this.contextFile, ImportFlags.NoAliasing | ImportFlags.AllowTypeImports); assertSuccessfulReferenceEmit(emittedType, target, 'type'); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts index bc97ea3ae2d8c..edd234dfaa23e 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts @@ -175,9 +175,9 @@ export class Environment implements ReferenceEmitEnvironment { /** * Generates a `ts.TypeNode` representing a type that is being referenced from a different place * in the program. Any type references inside the transplanted type will be rewritten so that - * they can be imported in the context fiel. + * they can be imported in the context file. */ - referenceTransplantedType(type: TransplantedType): ts.TypeNode { + referenceTransplantedType(type: TransplantedType>): ts.TypeNode { return translateType( type, this.contextFile, this.reflector, this.refEmitter, this.importManager); } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index e0534583c2ca5..ae6c8acf4407e 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -717,7 +717,7 @@ class TcbDirectiveInputsOp extends TcbOp { if (this.dir.coercedInputFields.has(fieldName)) { let type: ts.TypeNode; - if (transformType) { + if (transformType !== null) { type = this.tcb.env.referenceTransplantedType(new TransplantedType(transformType)); } else { // The input has a coercion declaration which should be used instead of assigning the @@ -2067,7 +2067,10 @@ class Scope { interface TcbBoundAttribute { attribute: TmplAstBoundAttribute|TmplAstTextAttribute; - inputs: {fieldName: ClassPropertyName, required: boolean, transformType: ts.TypeNode|null}[]; + inputs: + {fieldName: ClassPropertyName, + required: boolean, + transformType: Reference|null}[]; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts index d3792fdeed763..4e92ededbe647 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts @@ -150,7 +150,7 @@ function constructTypeCtorParameter( /* type */ transform == null ? tsCreateTypeQueryForCoercedInput(rawType.typeName, classPropertyName) : - transform.type)); + transform.type.node)); } } if (plainKeys.length > 0) { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 6c5b4fc9e5a08..d4f92b5e5aa7b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -9,6 +9,7 @@ import ts from 'typescript'; import {initMockFileSystem} from '../../file_system/testing'; +import {Reference} from '../../imports'; import {TypeCheckingConfig} from '../api'; import {ALL_ENABLED_CONFIG, tcb, TestDeclaration, TestDirective} from '../testing'; @@ -611,10 +612,10 @@ describe('type check blocks', () => { transform: { node: ts.factory.createFunctionDeclaration( undefined, undefined, undefined, undefined, [], undefined, undefined), - type: ts.factory.createUnionTypeNode([ + type: new Reference(ts.factory.createUnionTypeNode([ ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ]) + ])) }, }, }, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts index 8fb26caa7d4a3..9c5cf005d6497 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts @@ -171,10 +171,10 @@ TestClass.ngTypeCtor({value: 'test'}); bindingPropertyName: 'baz', required: false, transform: { - type: ts.factory.createUnionTypeNode([ + type: new Reference(ts.factory.createUnionTypeNode([ ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ]), + ])), node: ts.factory.createFunctionDeclaration( undefined, undefined, undefined, undefined, [], undefined, undefined) } diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 77b290aa5f365..3da7a2d7d11f1 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8713,7 +8713,7 @@ function allTests(os: string) { expect(jsContents).toContain(`import { externalToNumber } from 'external';`); expect(jsContents).toContain('inputs: { value: ["value", "value", externalToNumber] }'); expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); - expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";'); + expect(dtsContents).toContain('import * as i1 from "external";'); expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;'); }); @@ -8744,7 +8744,7 @@ function allTests(os: string) { expect(jsContents) .toContain('inputs: { value: ["value", "value", (value) => value ? 1 : 0] }'); expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); - expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";'); + expect(dtsContents).toContain('import * as i1 from "external";'); expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;'); }); From 53d9fd54497aa613a7a6951eaaf46ddd02223ca1 Mon Sep 17 00:00:00 2001 From: Mayuresh Dharwadkar <98738585+Mayureshd-18@users.noreply.github.com> Date: Sun, 29 Oct 2023 16:46:37 +0530 Subject: [PATCH 40/52] docs: Fix typos (#52442) PR Close #52442 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2125c845525c5..2043913947488 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Help us keep Angular open and inclusive. Please read and follow our [Code of Con Join the conversation and help the community. -- [Twitter][twitter] +- [X (formerly Twitter)][X (formerly Twitter)] - [Discord][discord] - [Gitter][gitter] - [YouTube][youtube] @@ -158,7 +158,7 @@ Join the conversation and help the community. [node.js]: https://nodejs.org/ [npm]: https://www.npmjs.com/get-npm [codeofconduct]: CODE_OF_CONDUCT.md -[twitter]: https://www.twitter.com/angular +[X (formerly Twitter)]: https://www.twitter.com/angular [discord]: https://discord.gg/angular [gitter]: https://gitter.im/angular/angular [stackoverflow]: https://stackoverflow.com/questions/tagged/angular From c5980d6b5f26b2a819330e1f276d349bd7948039 Mon Sep 17 00:00:00 2001 From: cexbrayat Date: Mon, 30 Oct 2023 07:57:10 +0100 Subject: [PATCH 41/52] fix(core): handle aliased index with no space in control flow migration (#52444) The current regexp supposes that there is at least one space between the `=` and the aliased variable. As it is possible to write `let myIndex=index`, this commit updates the regexp to handle such a case. PR Close #52444 --- .../control-flow-migration/util.ts | 2 +- .../test/control_flow_migration_spec.ts | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/util.ts b/packages/core/schematics/ng-generate/control-flow-migration/util.ts index fd0d362d865de..7eb271756c8af 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/util.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/util.ts @@ -315,7 +315,7 @@ function buildIfThenElseBlock( } function migrateNgFor(etm: ElementToMigrate, tmpl: string, offset: number): Result { - const aliasWithEqualRegexp = /=\s+(count|index|first|last|even|odd)/gm; + const aliasWithEqualRegexp = /=\s*(count|index|first|last|even|odd)/gm; const aliasWithAsRegexp = /(count|index|first|last|even|odd)\s+as/gm; const aliases = []; const lbString = etm.hasLineBreaks ? lb : ''; diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index 93886eb2bab76..7c89d6343bb70 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -869,7 +869,6 @@ describe('control flow migration', () => { 'template: `
      @for (itm of items; track trackMeFn($index, itm)) {
    • {{itm.text}}
    • }
    `'); }); - it('should migrate with an index alias', async () => { writeFile('/comp.ts', ` import {Component} from '@angular/core'; @@ -895,6 +894,31 @@ describe('control flow migration', () => { 'template: `
      @for (itm of items; track itm; let index = $index) {
    • {{itm.text}}
    • }
    `'); }); + it('should migrate with an index alias with no spaces', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
    • {{itm.text}}
    \` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
      @for (itm of items; track itm; let index = $index) {
    • {{itm.text}}
    • }
    `'); + }); + it('should migrate with alias declared with as', async () => { writeFile('/comp.ts', ` import {Component} from '@angular/core'; From 19a426d54e8bb4c8fdfb1b66dadbccc4bafbfb29 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 30 Oct 2023 09:12:37 +0000 Subject: [PATCH 42/52] build: update node.js engines version to be more explicate about v20 support (#52448) This commit adds Node.js 20 as explicitly supported version to match the Angular CLI engines. See: https://github.com/angular/angular-cli/pull/26173 PR Close #52448 --- package.json | 2 +- packages/animations/package.json | 2 +- packages/bazel/package.json | 2 +- packages/common/package.json | 2 +- packages/compiler-cli/package.json | 2 +- packages/compiler/package.json | 2 +- packages/core/package.json | 2 +- packages/elements/package.json | 2 +- packages/forms/package.json | 2 +- packages/language-service/package.json | 2 +- packages/localize/package.json | 2 +- packages/platform-browser-dynamic/package.json | 2 +- packages/platform-browser/package.json | 2 +- packages/platform-server/package.json | 2 +- packages/router/package.json | 2 +- packages/service-worker/package.json | 2 +- packages/upgrade/package.json | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 2f448dd57d7a0..f5c346632e90f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "license": "MIT", "//engines-comment": "Keep this in sync with /aio/package.json and /aio/tools/examples/shared/package.json", "engines": { - "node": "^18.13.0 || >=20.0.0", + "node": "^18.13.0 || ^20.9.0", "yarn": ">=1.22.4 <2", "npm": "Please use yarn instead of NPM to install dependencies" }, diff --git a/packages/animations/package.json b/packages/animations/package.json index 74ad3b24b3bd7..0660438f09469 100644 --- a/packages/animations/package.json +++ b/packages/animations/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/bazel/package.json b/packages/bazel/package.json index f2e5fd02d7a18..954acd68433e7 100644 --- a/packages/bazel/package.json +++ b/packages/bazel/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "bin": { "ngc-wrapped": "./src/ngc-wrapped/index.mjs", diff --git a/packages/common/package.json b/packages/common/package.json index 4d2698949b6fa..56d92e2dd7f87 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "locales": "locales", "dependencies": { diff --git a/packages/compiler-cli/package.json b/packages/compiler-cli/package.json index b048d6d1fa448..94f94fe533a64 100644 --- a/packages/compiler-cli/package.json +++ b/packages/compiler-cli/package.json @@ -67,7 +67,7 @@ ], "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "bugs": { "url": "https://github.com/angular/angular/issues" diff --git a/packages/compiler/package.json b/packages/compiler/package.json index dffa0ef7eca73..36346b0a67e14 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/core/package.json b/packages/core/package.json index 02eddda50c04c..e6fe54f804f8c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "exports": { "./schematics/*": { diff --git a/packages/elements/package.json b/packages/elements/package.json index 6b66cd9a559cb..744dba20b4cfe 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/forms/package.json b/packages/forms/package.json index 6fed09f0c1c2c..67ac4724e52b8 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/language-service/package.json b/packages/language-service/package.json index 60b46bd01378e..37e75bb859636 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -7,7 +7,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "exports": { ".": { diff --git a/packages/localize/package.json b/packages/localize/package.json index fc05dc8fb3886..69e12ae192df5 100644 --- a/packages/localize/package.json +++ b/packages/localize/package.json @@ -16,7 +16,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "repository": { "type": "git", diff --git a/packages/platform-browser-dynamic/package.json b/packages/platform-browser-dynamic/package.json index 6146ac2d50079..3c418fd157830 100644 --- a/packages/platform-browser-dynamic/package.json +++ b/packages/platform-browser-dynamic/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/platform-browser/package.json b/packages/platform-browser/package.json index ed6a6e03270b4..54267349480f7 100644 --- a/packages/platform-browser/package.json +++ b/packages/platform-browser/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 2320916b6cbee..b872a8076b196 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { "@angular/animations": "0.0.0-PLACEHOLDER", diff --git a/packages/router/package.json b/packages/router/package.json index cf326e3cc6c7e..e2bdb638425dd 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -14,7 +14,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "bugs": { "url": "https://github.com/angular/angular/issues" diff --git a/packages/service-worker/package.json b/packages/service-worker/package.json index 8df6727b84130..29c9658141599 100644 --- a/packages/service-worker/package.json +++ b/packages/service-worker/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "exports": { "./ngsw-worker.js": { diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index 07cc7eb8cca39..117d0baf3e23f 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -5,7 +5,7 @@ "author": "angular", "license": "MIT", "engines": { - "node": ">=18.13.0" + "node": "^18.13.0 || >=20.9.0" }, "dependencies": { "tslib": "^2.3.0" From 078ebeab00ef15742c2ce92113b8e23fcc09008c Mon Sep 17 00:00:00 2001 From: AleksanderBodurri Date: Mon, 30 Oct 2023 10:28:57 -0400 Subject: [PATCH 43/52] fix(core): mutation bug in `getDependenciesFromInjectable` (#52450) Previously, we would modified `dep.flags` directly to convert injection flags to booleans. This caused a mutation bug where subsequent calls to `getDependenciesFromInjectable` would result in the flags object containing false for every injection flag. Now, we stop modifying `dep.flags` directly and instead assign the converted flags to a new object. PR Close #52450 --- .../render3/util/injector_discovery_utils.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/core/src/render3/util/injector_discovery_utils.ts b/packages/core/src/render3/util/injector_discovery_utils.ts index 931c2c8e3219c..bb8317c9f762b 100644 --- a/packages/core/src/render3/util/injector_discovery_utils.ts +++ b/packages/core/src/render3/util/injector_discovery_utils.ts @@ -57,26 +57,32 @@ export function getDependenciesFromInjectable( const resolutionPath = getInjectorResolutionPath(injector); const dependencies = unformattedDependencies.map(dep => { + // injectedIn contains private fields, so we omit it from the response + const formattedDependency: Omit = { + value: dep.value, + }; + // convert injection flags to booleans const flags = dep.flags as InternalInjectFlags; - dep.flags = { + formattedDependency.flags = { optional: (InternalInjectFlags.Optional & flags) === InternalInjectFlags.Optional, host: (InternalInjectFlags.Host & flags) === InternalInjectFlags.Host, self: (InternalInjectFlags.Self & flags) === InternalInjectFlags.Self, skipSelf: (InternalInjectFlags.SkipSelf & flags) === InternalInjectFlags.SkipSelf, }; + // find the injector that provided the dependency for (let i = 0; i < resolutionPath.length; i++) { const injectorToCheck = resolutionPath[i]; // if skipSelf is true we skip the first injector - if (i === 0 && dep.flags.skipSelf) { + if (i === 0 && formattedDependency.flags.skipSelf) { continue; } // host only applies to NodeInjectors - if (dep.flags.host && injectorToCheck instanceof EnvironmentInjector) { + if (formattedDependency.flags.host && injectorToCheck instanceof EnvironmentInjector) { break; } @@ -88,36 +94,29 @@ export function getDependenciesFromInjectable( // in the resolution path by using the host flag. This is done to make sure that we've found // the correct providing injector, and not a node injector that is connected to our path via // a router outlet. - if (dep.flags.host) { + if (formattedDependency.flags.host) { const firstInjector = resolutionPath[0]; - const lookupFromFirstInjector = - firstInjector.get(dep.token as Type, null, {...dep.flags, optional: true}); + const lookupFromFirstInjector = firstInjector.get( + dep.token as Type, null, {...formattedDependency.flags, optional: true}); if (lookupFromFirstInjector !== null) { - dep.providedIn = injectorToCheck; + formattedDependency.providedIn = injectorToCheck; } break; } - dep.providedIn = injectorToCheck; + formattedDependency.providedIn = injectorToCheck; break; } // if self is true we stop after the first injector - if (i === 0 && dep.flags.self) { + if (i === 0 && formattedDependency.flags.self) { break; } } - // injectedIn contains private fields, so we omit it from the response - const formattedDependency: Omit = { - value: dep.value, - }; - if (dep.token) formattedDependency.token = dep.token; - if (dep.flags) formattedDependency.flags = dep.flags; - if (dep.providedIn) formattedDependency.providedIn = dep.providedIn; return formattedDependency; }); From eb153584791a30a015469269a62fc9eb43dbf16a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 27 Oct 2023 09:47:35 +0200 Subject: [PATCH 44/52] fix(compiler): project control flow root elements into correct slot (#52414) With the directive-based control flow users were able to conditionally project content using the `*` syntax. E.g. `
    ` will be projected into ``, because the attributes and tag name from the `div` are copied to the template via the template creation instruction. With `@if` and `@for` that is not the case, because the conditional is placed *around* elements, rather than *on* them. The result is that content projection won't work in the same way if a user converts from `*ngIf` to `@if`. These changes aim to cover the most common case by doing the same copying when a control flow node has *one and only one* root element or template node. This approach comes with some caveats: 1. As soon as any other node is added to the root, the copying behavior won't work anymore. A diagnostic will be added to flag cases like this and to explain how to work around it. 2. If `preserveWhitespaces` is enabled, it's very likely that indentation will break this workaround, because it'll include an additional text node as the first child. We can work around it here, but in a discussion it was decided not to, because the user explicitly opted into preserving the whitespace and we would have to drop it from the generated code. The diagnostic mentioned point #1 will flag such cases to users. Fixes #52277. PR Close #52414 --- .../GOLDEN_PARTIAL.js | 74 ++++ .../TEST_CASES.json | 36 +- .../basic_for_template.js | 2 +- ...for_aliased_template_variables_template.js | 2 +- .../for_data_slots_template.js | 2 +- .../for_element_root_node.ts | 12 + .../for_element_root_node_template.js | 3 + .../for_impure_track_reuse_template.js | 4 +- .../for_pure_track_reuse_template.js | 4 +- ...r_template_track_method_nested_template.js | 2 +- ...for_template_track_method_root_template.js | 2 +- ...or_template_variables_listener_template.js | 2 +- .../for_template_variables_scope_template.js | 2 +- .../for_template_variables_template.js | 2 +- .../for_track_by_field_template.js | 2 +- .../for_track_by_index_template.js | 2 +- .../for_track_literals_template.js | 2 +- .../for_with_empty_template.js | 2 +- .../for_with_pipe_template.js | 2 +- .../if_element_root_node.ts | 12 + .../if_element_root_node_template.js | 3 + .../if_nested_alias_listeners_template.js | 2 +- ...ested_alias_listeners_template.pipeline.js | 2 +- .../nested_for_template.js | 4 +- .../nested_for_template_variables_template.js | 4 +- .../compiler/src/render3/view/template.ts | 69 +++- .../src/render3/instructions/control_flow.ts | 10 +- .../test/acceptance/control_flow_for_spec.ts | 298 +++++++++++++++- .../test/acceptance/control_flow_if_spec.ts | 326 +++++++++++++++++- .../acceptance/control_flow_switch_spec.ts | 29 ++ 30 files changed, 883 insertions(+), 35 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node_template.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js index c1f199d0915a9..ae75ec0c9c302 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js @@ -1652,3 +1652,77 @@ export declare class MyApp { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: if_element_root_node.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.expr = true; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: ` + @if (expr) { +
    {{expr}}
    + } + `, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ + type: Component, + args: [{ + template: ` + @if (expr) { +
    {{expr}}
    + } + `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: if_element_root_node.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + expr: boolean; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: for_element_root_node.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.items = [1, 2, 3]; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: ` + @for (item of items; track item) { +
    {{item}}
    + } + `, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ + type: Component, + args: [{ + template: ` + @for (item of items; track item) { +
    {{item}}
    + } + `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: for_element_root_node.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + items: number[]; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json index 408a5e3e11db6..e2518c4c9a557 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json @@ -513,6 +513,40 @@ "failureMessage": "Incorrect template" } ] + }, + { + "description": "should generate an if block with an element root node", + "inputFiles": [ + "if_element_root_node.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "if_element_root_node_template.js", + "generated": "if_element_root_node.js" + } + ], + "failureMessage": "Incorrect template" + } + ] + }, + { + "description": "should generate a for block with an element root node", + "inputFiles": [ + "for_element_root_node.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "for_element_root_node_template.js", + "generated": "for_element_root_node.js" + } + ], + "failureMessage": "Incorrect template" + } + ] } ] -} \ No newline at end of file +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/basic_for_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/basic_for_template.js index 31c3f65439ac6..fd44ceaa68f43 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/basic_for_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/basic_for_template.js @@ -12,7 +12,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_aliased_template_variables_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_aliased_template_variables_template.js index 37bb40bff2c88..4fca43229b52a 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_aliased_template_variables_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_aliased_template_variables_template.js @@ -13,7 +13,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 6, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 6, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_data_slots_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_data_slots_template.js index 3b8be52e4c5c8..8a8fd335f5727 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_data_slots_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_data_slots_template.js @@ -1,7 +1,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyApp_ng_template_0_Template, 0, 0, "ng-template"); - $r3$.ɵɵrepeaterCreate(1, MyApp_For_2_Template, 1, 1, $r3$.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_3_Template, 1, 0); + $r3$.ɵɵrepeaterCreate(1, MyApp_For_2_Template, 1, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_3_Template, 1, 0); $r3$.ɵɵtemplate(4, MyApp_ng_template_4_Template, 0, 0, "ng-template"); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts new file mode 100644 index 0000000000000..06aaa1c307cc4 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @for (item of items; track item) { +
    {{item}}
    + } + `, +}) +export class MyApp { + items = [1, 2, 3]; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js new file mode 100644 index 0000000000000..dd8338a797cc5 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js @@ -0,0 +1,3 @@ +consts: [["foo", "1", "bar", "2"]] +… +$r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 2, 1, "div", 0, i0.ɵɵrepeaterTrackByIdentity); \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_impure_track_reuse_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_impure_track_reuse_template.js index d379c718fb978..8c0e1e1172a56 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_impure_track_reuse_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_impure_track_reuse_template.js @@ -4,8 +4,8 @@ function $_forTrack0$($index, $item) { … function MyApp_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, $_forTrack0$, true); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $_forTrack0$, true); + $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, null, null, $_forTrack0$, true); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $_forTrack0$, true); } if (rf & 2) { $r3$.ɵɵrepeater(0, ctx.items); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_pure_track_reuse_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_pure_track_reuse_template.js index f6106c45c27ac..9591ce6e32a85 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_pure_track_reuse_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_pure_track_reuse_template.js @@ -2,8 +2,8 @@ const $_forTrack0$ = ($index, $item) => $item.name[0].toUpperCase(); … function MyApp_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, $_forTrack0$); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $_forTrack0$); + $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, null, null, $_forTrack0$); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $_forTrack0$); } if (rf & 2) { $r3$.ɵɵrepeater(0, ctx.items); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_nested_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_nested_template.js index 0e6d3e7a51ea7..455b9815c71cd 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_nested_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_nested_template.js @@ -1 +1 @@ -$r3$.ɵɵrepeaterCreate(0, MyApp_ng_template_2_For_1_Template, 0, 0, $r3$.ɵɵcomponentInstance().trackFn); +$r3$.ɵɵrepeaterCreate(0, MyApp_ng_template_2_For_1_Template, 0, 0, null, null, $r3$.ɵɵcomponentInstance().trackFn); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_root_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_root_template.js index d4060f074cbeb..115571f10f797 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_root_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_track_method_root_template.js @@ -1 +1 @@ -$r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 0, 0, ctx.trackFn); +$r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 0, 0, null, null, ctx.trackFn); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_listener_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_listener_template.js index 7d92b1a16adf7..45846900ac0b9 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_listener_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_listener_template.js @@ -17,7 +17,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 0, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 0, "div", null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_scope_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_scope_template.js index 318d36737e95b..a94b4d6936ca8 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_scope_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_scope_template.js @@ -12,7 +12,7 @@ function MyApp_For_2_Template(rf, ctx) { function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0); - $r3$.ɵɵrepeaterCreate(1, MyApp_For_2_Template, 1, 4, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(1, MyApp_For_2_Template, 1, 4, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵtext(3); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_template.js index 2b59b289be744..6399e6cddf7ef 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_variables_template.js @@ -13,7 +13,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 6, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 6, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_field_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_field_template.js index 90371fa666bde..52a63bffe47c9 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_field_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_field_template.js @@ -13,7 +13,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $_forTrack0$); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $_forTrack0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_index_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_index_template.js index d34618a703d69..adc50556651dc 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_index_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_by_index_template.js @@ -12,7 +12,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $r3$.ɵɵrepeaterTrackByIndex); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $r3$.ɵɵrepeaterTrackByIndex); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_literals_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_literals_template.js index 0f9b7bc192442..83c18b778068a 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_literals_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_track_literals_template.js @@ -7,7 +7,7 @@ function $_forTrack0$($index, $item) { … function MyApp_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, $_forTrack0$, true); + $r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 1, null, null, $_forTrack0$, true); } if (rf & 2) { $r3$.ɵɵrepeater(0, ctx.items); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_empty_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_empty_template.js index f4f411557d8a0..59273aaa1add2 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_empty_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_empty_template.js @@ -18,7 +18,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $r3$.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_4_Template, 1, 0); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_4_Template, 1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_pipe_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_pipe_template.js index f48267ae32b85..11a64cfb7d004 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_pipe_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_with_pipe_template.js @@ -2,7 +2,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 1, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵpipe(4, "test"); $r3$.ɵɵelementEnd(); } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node.ts new file mode 100644 index 0000000000000..7b18ca80b11cb --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @if (expr) { +
    {{expr}}
    + } + `, +}) +export class MyApp { + expr = true; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node_template.js new file mode 100644 index 0000000000000..e45728626643c --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_element_root_node_template.js @@ -0,0 +1,3 @@ +consts: [["foo", "1", "bar", "2"]] +… +$r3$.ɵɵtemplate(0, MyApp_Conditional_0_Template, 2, 1, "div", 0); \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.js index f4b5295d40173..a83502f3eac68 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.js @@ -26,7 +26,7 @@ function MyApp_Conditional_0_Conditional_1_Template(rf, ctx) { return $r3$.ɵɵresetView($ctx_r10$.log($ctx_r10$.value(), $root_r1$, $inner_r3$)); }); $r3$.ɵɵelementEnd(); - $r3$.ɵɵtemplate(1, MyApp_Conditional_0_Conditional_1_Conditional_1_Template, 1, 0); + $r3$.ɵɵtemplate(1, MyApp_Conditional_0_Conditional_1_Conditional_1_Template, 1, 0, "button"); } if (rf & 2) { const $ctx_r2$ = $r3$.ɵɵnextContext(2); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.pipeline.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.pipeline.js index 8f9c6c5799991..52123af67115e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.pipeline.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/if_nested_alias_listeners_template.pipeline.js @@ -24,7 +24,7 @@ function MyApp_Conditional_0_Conditional_1_Conditional_1_Template(rf, ctx) { return $r3$.ɵɵresetView($ctx_r10$.log($ctx_r10$.value(), $root_r1$, $inner_r3$)); }); $r3$.ɵɵelementEnd(); - $r3$.ɵɵtemplate(1, MyApp_Conditional_0_Conditional_1_Conditional_1_Template, 1, 0); + $r3$.ɵɵtemplate(1, MyApp_Conditional_0_Conditional_1_Conditional_1_Template, 1, 0, "button"); } if (rf & 2) { let $MyApp_Conditional_0_Conditional_1_contFlowTmp$; diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template.js index bd60b00b9577f..0f82c2acdbe49 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template.js @@ -12,7 +12,7 @@ function MyApp_For_3_For_2_Template(rf, ctx) { function MyApp_For_3_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0); - $r3$.ɵɵrepeaterCreate(1, MyApp_For_3_For_2_Template, 1, 2, $r3$.ɵɵrepeaterTrackByIndex); + $r3$.ɵɵrepeaterCreate(1, MyApp_For_3_For_2_Template, 1, 2, null, null, $r3$.ɵɵrepeaterTrackByIndex); } if (rf & 2) { const $item_r1$ = ctx.$implicit; @@ -25,7 +25,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 3, 1, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 3, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template_variables_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template_variables_template.js index 40a371c87b6ae..12bbc9d44a88c 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template_variables_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/nested_for_template_variables_template.js @@ -12,7 +12,7 @@ function MyApp_For_3_For_2_Template(rf, ctx) { function MyApp_For_3_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0); - $r3$.ɵɵrepeaterCreate(1, MyApp_For_3_For_2_Template, 1, 2, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(1, MyApp_For_3_For_2_Template, 1, 2, null, null, $r3$.ɵɵrepeaterTrackByIdentity); } if (rf & 2) { const $item_r1$ = ctx.$implicit; @@ -25,7 +25,7 @@ function MyApp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵtext(1); - $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 3, 1, $r3$.ɵɵrepeaterTrackByIdentity); + $r3$.ɵɵrepeaterCreate(2, MyApp_For_3_Template, 3, 1, null, null, $r3$.ɵɵrepeaterTrackByIdentity); $r3$.ɵɵelementEnd(); } if (rf & 2) { diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 84740788924a6..49dedf2efc565 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -53,6 +53,9 @@ const NG_PROJECT_AS_ATTR_NAME = 'ngProjectAs'; // Global symbols available only inside event bindings. const EVENT_BINDING_SCOPE_GLOBALS = new Set(['$event']); +// Tag name of the `ng-template` element. +const NG_TEMPLATE_TAG_NAME = 'ng-template'; + // List of supported global targets for event listeners const GLOBAL_TARGET_RESOLVERS = new Map( [['window', R3.resolveWindow], ['document', R3.resolveDocument], ['body', R3.resolveBody]]); @@ -998,7 +1001,6 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const tagNameWithoutNamespace = template.tagName ? splitNsName(template.tagName)[1] : template.tagName; const contextNameSuffix = template.tagName ? '_' + sanitizeIdentifier(template.tagName) : ''; - const NG_TEMPLATE_TAG_NAME = 'ng-template'; // prepare attributes parameter (including attributes used for directive matching) const attrsExprs: o.Expression[] = this.getAttributeExpressions( @@ -1148,7 +1150,9 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // We have to process the block in two steps: once here and again in the update instruction // callback in order to generate the correct expressions when pipes or pure functions are // used inside the branch expressions. - const branchData = block.branches.map(({expression, expressionAlias, children, sourceSpan}) => { + const branchData = block.branches.map((branch, branchIndex) => { + const {expression, expressionAlias, children, sourceSpan} = branch; + // If the branch has an alias, it'll be assigned directly to the container's context. // We define a variable referring directly to the context so that any nested usages can be // rewritten to refer to it. @@ -1158,13 +1162,24 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver expressionAlias.keySpan)] : undefined; + let tagName: string|null = null; + let attrsExprs: o.Expression[]|undefined; + + // Only the first branch can be used for projection, because the conditional + // uses the container of the first branch as the insertion point for all branches. + if (branchIndex === 0) { + const inferredData = this.inferProjectionDataFromInsertionPoint(branch); + tagName = inferredData.tagName; + attrsExprs = inferredData.attrsExprs; + } + // Note: the template needs to be created *before* we process the expression, // otherwise pipes injecting some symbols won't work (see #52102). - const index = - this.createEmbeddedTemplateFn(null, children, '_Conditional', sourceSpan, variables); + const templateIndex = this.createEmbeddedTemplateFn( + tagName, children, '_Conditional', sourceSpan, variables, attrsExprs); const processedExpression = expression === null ? null : expression.visit(this._valueConverter); - return {index, expression: processedExpression, alias: expressionAlias}; + return {index: templateIndex, expression: processedExpression, alias: expressionAlias}; }); // Use the index of the first block as the index for the entire container. @@ -1460,6 +1475,47 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver }); } + /** + * Infers the data used for content projection (tag name and attributes) from the content of a + * node. + * @param node Node for which to infer the projection data. + */ + private inferProjectionDataFromInsertionPoint(node: t.IfBlockBranch|t.ForLoopBlock) { + let root: t.Element|t.Template|null = null; + let tagName: string|null = null; + let attrsExprs: o.Expression[]|undefined; + + for (const child of node.children) { + // Skip over comment nodes. + if (child instanceof t.Comment) { + continue; + } + + // We can only infer the tag name/attributes if there's a single root node. + if (root !== null) { + root = null; + break; + } + + // Root nodes can only elements or templates with a tag name (e.g. `
    `). + if (child instanceof t.Element || (child instanceof t.Template && child.tagName !== null)) { + root = child; + } + } + + // If we've found a single root node, its tag name and *static* attributes can be copied + // to the surrounding template to be used for content projection. Note that it's important + // that we don't copy any bound attributes since they don't participate in content projection + // and they can be used in directive matching (in the case of `Template.templateAttrs`). + if (root !== null) { + tagName = root instanceof t.Element ? root.name : root.tagName; + attrsExprs = + this.getAttributeExpressions(NG_TEMPLATE_TAG_NAME, root.attributes, root.inputs, []); + } + + return {tagName, attrsExprs}; + } + private allocateDataSlot() { return this._dataIndex++; } @@ -1468,6 +1524,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // Allocate one slot for the repeater metadata. The slots for the primary and empty block // are implicitly inferred by the runtime to index + 1 and index + 2. const blockIndex = this.allocateDataSlot(); + const {tagName, attrsExprs} = this.inferProjectionDataFromInsertionPoint(block); const primaryData = this.prepareEmbeddedTemplateFn( block.children, '_For', [block.item, block.contextVariables.$index, block.contextVariables.$count]); @@ -1490,6 +1547,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver o.variable(primaryData.name), o.literal(primaryData.getConstCount()), o.literal(primaryData.getVarCount()), + o.literal(tagName), + this.addAttrsToConsts(attrsExprs || null), trackByExpression, ]; diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index 78fa79006f284..2c44e9ad8fcb6 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -125,6 +125,8 @@ class RepeaterMetadata { * @param templateFn Reference to the template of the main repeater block. * @param decls The number of nodes, local refs, and pipes for the main block. * @param vars The number of bindings for the main block. + * @param tagName The name of the container element, if applicable + * @param attrsIndex Index of template attributes in the `consts` array. * @param trackByFn Reference to the tracking function. * @param trackByUsesComponentInstance Whether the tracking function has any references to the * component instance. If it doesn't, we can avoid rebinding it. @@ -136,10 +138,10 @@ class RepeaterMetadata { */ export function ɵɵrepeaterCreate( index: number, templateFn: ComponentTemplate, decls: number, vars: number, - trackByFn: TrackByFunction, trackByUsesComponentInstance?: boolean, - emptyTemplateFn?: ComponentTemplate, emptyDecls?: number, emptyVars?: number): void { + tagName: string|null, attrsIndex: number|null, trackByFn: TrackByFunction, + trackByUsesComponentInstance?: boolean, emptyTemplateFn?: ComponentTemplate, + emptyDecls?: number, emptyVars?: number): void { performance.mark('mark_use_counter', PERF_MARK_CONTROL_FLOW); - const hasEmptyBlock = emptyTemplateFn !== undefined; const hostLView = getLView(); const boundTrackBy = trackByUsesComponentInstance ? @@ -150,7 +152,7 @@ export function ɵɵrepeaterCreate( const metadata = new RepeaterMetadata(hasEmptyBlock, boundTrackBy); hostLView[HEADER_OFFSET + index] = metadata; - ɵɵtemplate(index + 1, templateFn, decls, vars); + ɵɵtemplate(index + 1, templateFn, decls, vars, tagName, attrsIndex); if (hasEmptyBlock) { ngDevMode && diff --git a/packages/core/test/acceptance/control_flow_for_spec.ts b/packages/core/test/acceptance/control_flow_for_spec.ts index 1666ba84fb4b8..e3f2457a4d88e 100644 --- a/packages/core/test/acceptance/control_flow_for_spec.ts +++ b/packages/core/test/acceptance/control_flow_for_spec.ts @@ -7,7 +7,8 @@ */ -import {ChangeDetectorRef, Component, inject, Pipe, PipeTransform} from '@angular/core'; +import {NgIf} from '@angular/common'; +import {ChangeDetectorRef, Component, Directive, inject, OnInit, Pipe, PipeTransform, TemplateRef, ViewContainerRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; describe('control flow - for', () => { @@ -277,4 +278,299 @@ describe('control flow - for', () => { expect(fixture.nativeElement.textContent).toBe('5(0)|3(1)|7(2)|'); }); }); + + describe('content projection', () => { + it('should project an @for with a single root node into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) { + {{item}} + } After + ` + }) + class App { + items = [1, 2, 3]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); + }); + + it('should project an @for with multiple root nodes into the catch-all slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) { + one{{item}} +
    two{{item}}
    + } After
    + ` + }) + class App { + items = [1, 2]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before one1two1one2two2 After Slot: '); + }); + + // Right now the template compiler doesn't collect comment nodes. + // This test is to ensure that we don't regress if it happens in the future. + it('should project an @for with single root node and comments into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) { + + {{item}} + + } After + ` + }) + class App { + items = [1, 2, 3]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); + }); + + it('should project the root node when preserveWhitespaces is enabled and there are no whitespace nodes', + () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + preserveWhitespaces: true, + // Note the whitespace due to the indentation inside @for. + template: + 'Before @for (item of items; track $index) {{{item}}} After' + }) + class App { + items = [1, 2, 3]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); + }); + + it('should not project the root node when preserveWhitespaces is enabled and there are whitespace nodes', + () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + preserveWhitespaces: true, + // Note the whitespace due to the indentation inside @for. + template: ` + Before @for (item of items; track $index) { + {{item}} + } After + ` + }) + class App { + items = [1, 2, 3]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent) + .toMatch(/Main: Before\s+1\s+2\s+3\s+After Slot:/); + }); + + it('should not project the root node across multiple layers of @for', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) { + @for (item of items; track $index) { + {{item}} + } + } After + ` + }) + class App { + items = [1, 2]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before 1212 After Slot: '); + }); + + it('should project an @for with a single root template node into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent, NgIf], + template: `Before @for (item of items; track $index) { + {{item}} + } After` + }) + class App { + items = [1, 2]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 12'); + + fixture.componentInstance.items.push(3); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); + }); + + it('should invoke a projected attribute directive at the root of an @for once', () => { + let directiveCount = 0; + + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Directive({ + selector: '[foo]', + standalone: true, + }) + class FooDirective { + constructor() { + directiveCount++; + } + } + + @Component({ + standalone: true, + imports: [TestComponent, FooDirective], + template: `Before @for (item of items; track $index) { + {{item}} + } After + ` + }) + class App { + items = [1]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(directiveCount).toBe(1); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 1'); + }); + + it('should invoke a projected template directive at the root of an @for once', () => { + let directiveCount = 0; + + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Directive({ + selector: '[templateDir]', + standalone: true, + }) + class TemplateDirective implements OnInit { + constructor( + private viewContainerRef: ViewContainerRef, + private templateRef: TemplateRef, + ) { + directiveCount++; + } + + ngOnInit(): void { + const view = this.viewContainerRef.createEmbeddedView(this.templateRef); + this.viewContainerRef.insert(view); + } + } + + @Component({ + standalone: true, + imports: [TestComponent, TemplateDirective], + template: `Before @for (item of items; track $index) { + {{item}} + } After + ` + }) + class App { + items = [1]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(directiveCount).toBe(1); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 1'); + }); + }); }); diff --git a/packages/core/test/acceptance/control_flow_if_spec.ts b/packages/core/test/acceptance/control_flow_if_spec.ts index 56bf414d5498b..fc79db9d8586f 100644 --- a/packages/core/test/acceptance/control_flow_if_spec.ts +++ b/packages/core/test/acceptance/control_flow_if_spec.ts @@ -7,7 +7,8 @@ */ -import {ChangeDetectorRef, Component, inject, Pipe, PipeTransform} from '@angular/core'; +import {NgFor} from '@angular/common'; +import {ChangeDetectorRef, Component, Directive, inject, OnInit, Pipe, PipeTransform, TemplateRef, ViewContainerRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; // Basic shared pipe used during testing. @@ -259,4 +260,327 @@ describe('control flow - if', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toBe('Something'); }); + + describe('content projection', () => { + it('should project an @if with a single root node into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @if (true) { + foo + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo'); + }); + + it('should project an @if with multiple root nodes into the catch-all slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @if (true) { + one +
    two
    + } After
    + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before onetwo After Slot: '); + }); + + // Right now the template compiler doesn't collect comment nodes. + // This test is to ensure that we don't regress if it happens in the future. + it('should project an @if with a single root node and comments into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @if (true) { + + foo + + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo'); + }); + + // Note: the behavior in this test is *not* intuitive, but it's meant to capture + // the projection behavior from `*ngIf` with `@if`. The test can be updated if we + // change how content projection works in the future. + it('should project an @else content into the slot of @if', () => { + @Component({ + standalone: true, + selector: 'test', + template: + 'Main: One: Two: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @if (value) { + one + } @else { + two + } After + ` + }) + class App { + value = true; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After One: one Two: '); + + fixture.componentInstance.value = false; + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After One: two Two: '); + }); + + it('should project the root node when preserveWhitespaces is enabled and there are no whitespace nodes', + () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + preserveWhitespaces: true, + template: 'Before @if (true) {one} After' + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: one'); + }); + + it('should not project the root node when preserveWhitespaces is enabled and there are whitespace nodes', + () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + preserveWhitespaces: true, + // Note the whitespace due to the indentation inside @if. + template: ` + Before @if (true) { + one + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toMatch(/Main: Before\s+one\s+After Slot:/); + }); + + it('should not project the root node across multiple layers of @if', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @if (true) { + @if (true) { + one + } + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toMatch(/Main: Before\s+one\s+After Slot:/); + }); + + it('should project an @if with a single root template node into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent, NgFor], + template: `Before @if (true) { + {{item}} + } After` + }) + class App { + items = [1, 2]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 12'); + + fixture.componentInstance.items.push(3); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); + }); + + it('should invoke a projected attribute directive at the root of an @if once', () => { + let directiveCount = 0; + + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Directive({ + selector: '[foo]', + standalone: true, + }) + class FooDirective { + constructor() { + directiveCount++; + } + } + + @Component({ + standalone: true, + imports: [TestComponent, FooDirective], + template: `Before @if (true) { + foo + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(directiveCount).toBe(1); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo'); + }); + + it('should invoke a projected template directive at the root of an @if once', () => { + let directiveCount = 0; + + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Directive({ + selector: '[templateDir]', + standalone: true, + }) + class TemplateDirective implements OnInit { + constructor( + private viewContainerRef: ViewContainerRef, + private templateRef: TemplateRef, + ) { + directiveCount++; + } + + ngOnInit(): void { + const view = this.viewContainerRef.createEmbeddedView(this.templateRef); + this.viewContainerRef.insert(view); + } + } + + @Component({ + standalone: true, + imports: [TestComponent, TemplateDirective], + template: `Before @if (true) { + foo + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(directiveCount).toBe(1); + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo'); + }); + }); }); diff --git a/packages/core/test/acceptance/control_flow_switch_spec.ts b/packages/core/test/acceptance/control_flow_switch_spec.ts index d002e7ac1ac3c..134e799139d1c 100644 --- a/packages/core/test/acceptance/control_flow_switch_spec.ts +++ b/packages/core/test/acceptance/control_flow_switch_spec.ts @@ -135,4 +135,33 @@ describe('control flow - switch', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toBe('One'); }); + + it('should project an @switch block into the catch-all slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @switch (1) { + @case (1) { + foo + } + } After + ` + }) + class App { + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before foo After Slot: '); + }); }); From c7b730eb721eec7f6a2f34dead2fff8088f3d15b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 31 Oct 2023 21:53:11 +0100 Subject: [PATCH 45/52] refactor(compiler): implement control flow content projection fix in template pipeline (#52414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreates the fix for content projection in control flow in the new template pipeline. I also had to make the following adjustments to the pipeline: 1. The `TemplateOp.tag` property was being used to generate the name of the template function, rather than the actual tag name being passed into `ɵɵtemplate`. Since the content projection fix requires the tag name to be passed in, I've introduced a new `functionNameSuffix` property instead. 2. `TemplateOp.block` was being used to determine whether to pass `TemplateOp.tag` into the `ɵɵtemplate` instruction. Now that we're always passing in the tag name after the refactor in point 1, we no longer need this flag. In addition to the refactors above, I also made some minor cleanups where I saw the opportunity to do so. PR Close #52414 --- .../template/pipeline/ir/src/ops/create.ts | 22 +++-- .../src/template/pipeline/src/ingest.ts | 95 ++++++++++++++++--- .../src/template/pipeline/src/instruction.ts | 15 +-- .../template/pipeline/src/phases/naming.ts | 15 +-- .../src/template/pipeline/src/phases/reify.ts | 7 +- 5 files changed, 113 insertions(+), 41 deletions(-) diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index 0b114f1afa5da..95a3c8f1239e0 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -183,11 +183,9 @@ export interface TemplateOp extends ElementOpBase { vars: number|null; /** - * Whether or not this template was automatically created for use with block syntax (control flow - * or defer). This will eventually cause the emitted template instruction to use fewer arguments, - * since several of the default arguments are unnecessary for blocks. + * Suffix to add to the name of the generated template function. */ - block: boolean; + functionNameSuffix: string; /** * The i18n placeholder data associated with this template. @@ -199,7 +197,7 @@ export interface TemplateOp extends ElementOpBase { * Create a `TemplateOp`. */ export function createTemplateOp( - xref: XrefId, tag: string|null, namespace: Namespace, generatedInBlock: boolean, + xref: XrefId, tag: string|null, functionNameSuffix: string, namespace: Namespace, i18nPlaceholder: i18n.TagPlaceholder|undefined, sourceSpan: ParseSourceSpan): TemplateOp { return { kind: OpKind.Template, @@ -207,7 +205,7 @@ export function createTemplateOp( attributes: null, tag, slot: new SlotHandle(), - block: generatedInBlock, + functionNameSuffix, decls: null, vars: null, localRefs: [], @@ -264,6 +262,11 @@ export interface RepeaterCreateOp extends ElementOpBase { */ usesComponentInstance: boolean; + /** + * Suffix to add to the name of the generated template function. + */ + functionNameSuffix: string; + sourceSpan: ParseSourceSpan; } @@ -279,8 +282,8 @@ export interface RepeaterVarNames { } export function createRepeaterCreateOp( - primaryView: XrefId, emptyView: XrefId|null, track: o.Expression, varNames: RepeaterVarNames, - sourceSpan: ParseSourceSpan): RepeaterCreateOp { + primaryView: XrefId, emptyView: XrefId|null, tag: string|null, track: o.Expression, + varNames: RepeaterVarNames, sourceSpan: ParseSourceSpan): RepeaterCreateOp { return { kind: OpKind.RepeaterCreate, attributes: null, @@ -289,7 +292,8 @@ export function createRepeaterCreateOp( emptyView, track, trackByFn: null, - tag: 'For', + tag, + functionNameSuffix: 'For', namespace: Namespace.HTML, nonBindable: false, localRefs: [], diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index ad213cbc1d8df..ebd9cc26c81a2 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -18,7 +18,7 @@ import {BindingParser} from '../../../template_parser/binding_parser'; import * as ir from '../ir'; import {ComponentCompilationJob, HostBindingCompilationJob, type CompilationJob, type ViewCompilationUnit} from './compilation'; -import {BINARY_OPERATORS, namespaceForKey} from './conversion'; +import {BINARY_OPERATORS, namespaceForKey, prefixWithNamespace} from './conversion'; const compatibilityMode = ir.CompatibilityMode.TemplateDefinitionBuilder; @@ -149,10 +149,6 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { throw Error(`Unhandled i18n metadata type for element: ${element.i18n.constructor.name}`); } - const staticAttributes: Record = {}; - for (const attr of element.attributes) { - staticAttributes[attr.name] = attr.value; - } const id = unit.job.allocateXrefId(); const [namespaceKey, elementName] = splitNsName(element.name); @@ -196,9 +192,13 @@ function ingestTemplate(unit: ViewCompilationUnit, tmpl: t.Template): void { } const i18nPlaceholder = tmpl.i18n instanceof i18n.TagPlaceholder ? tmpl.i18n : undefined; + const namespace = namespaceForKey(namespacePrefix); + const functionNameSuffix = tagNameWithoutNamespace === null ? + '' : + prefixWithNamespace(tagNameWithoutNamespace, namespace); const tplOp = ir.createTemplateOp( - childView.xref, tagNameWithoutNamespace, namespaceForKey(namespacePrefix), false, - i18nPlaceholder, tmpl.startSourceSpan); + childView.xref, tagNameWithoutNamespace, functionNameSuffix, namespace, i18nPlaceholder, + tmpl.startSourceSpan); unit.create.push(tplOp); ingestBindings(unit, tplOp, tmpl); @@ -281,13 +281,21 @@ function ingestIfBlock(unit: ViewCompilationUnit, ifBlock: t.IfBlock): void { let firstXref: ir.XrefId|null = null; let firstSlotHandle: ir.SlotHandle|null = null; let conditions: Array = []; - for (const ifCase of ifBlock.branches) { + for (let i = 0; i < ifBlock.branches.length; i++) { + const ifCase = ifBlock.branches[i]; const cView = unit.job.allocateView(unit.xref); + let tagName: string|null = null; + + // Only the first branch can be used for projection, because the conditional + // uses the container of the first branch as the insertion point for all branches. + if (i === 0) { + tagName = ingestControlFlowInsertionPoint(unit, cView.xref, ifCase); + } if (ifCase.expressionAlias !== null) { cView.contextVariables.set(ifCase.expressionAlias.name, ir.CTX_REF); } const tmplOp = ir.createTemplateOp( - cView.xref, 'Conditional', ir.Namespace.HTML, true, + cView.xref, tagName, 'Conditional', ir.Namespace.HTML, undefined /* TODO: figure out how i18n works with new control flow */, ifCase.sourceSpan); unit.create.push(tmplOp); @@ -295,6 +303,7 @@ function ingestIfBlock(unit: ViewCompilationUnit, ifBlock: t.IfBlock): void { firstXref = cView.xref; firstSlotHandle = tmplOp.slot; } + const caseExpr = ifCase.expression ? convertAst(ifCase.expression, unit.job, null) : null; const conditionalCaseExpr = new ir.ConditionalCaseExpr(caseExpr, tmplOp.xref, tmplOp.slot, ifCase.expressionAlias); @@ -316,7 +325,7 @@ function ingestSwitchBlock(unit: ViewCompilationUnit, switchBlock: t.SwitchBlock for (const switchCase of switchBlock.cases) { const cView = unit.job.allocateView(unit.xref); const tmplOp = ir.createTemplateOp( - cView.xref, 'Case', ir.Namespace.HTML, true, + cView.xref, null, 'Case', ir.Namespace.HTML, undefined /* TODO: figure out how i18n works with new control flow */, switchCase.sourceSpan); unit.create.push(tmplOp); @@ -346,7 +355,7 @@ function ingestDeferView( const secondaryView = unit.job.allocateView(unit.xref); ingestNodes(secondaryView, children); const templateOp = ir.createTemplateOp( - secondaryView.xref, `Defer${suffix}`, ir.Namespace.HTML, true, undefined, sourceSpan!); + secondaryView.xref, null, `Defer${suffix}`, ir.Namespace.HTML, undefined, sourceSpan!); unit.create.push(templateOp); return templateOp; } @@ -503,8 +512,9 @@ function ingestForBlock(unit: ViewCompilationUnit, forBlock: t.ForLoopBlock): vo $implicit: forBlock.item.name, }; + const tagName = ingestControlFlowInsertionPoint(unit, repeaterView.xref, forBlock); const repeaterCreate = ir.createRepeaterCreateOp( - repeaterView.xref, emptyView?.xref ?? null, track, varNames, forBlock.sourceSpan); + repeaterView.xref, emptyView?.xref ?? null, tagName, track, varNames, forBlock.sourceSpan); unit.create.push(repeaterCreate); const expression = convertAst( @@ -841,3 +851,64 @@ function convertSourceSpan( const fullStart = baseSourceSpan.fullStart.moveBy(span.start); return new ParseSourceSpan(start, end, fullStart); } + +/** + * With the directive-based control flow users were able to conditionally project content using + * the `*` syntax. E.g. `
    ` will be projected into + * ``, because the attributes and tag name from the `div` are + * copied to the template via the template creation instruction. With `@if` and `@for` that is + * not the case, because the conditional is placed *around* elements, rather than *on* them. + * The result is that content projection won't work in the same way if a user converts from + * `*ngIf` to `@if`. + * + * This function aims to cover the most common case by doing the same copying when a control flow + * node has *one and only one* root element or template node. + * + * This approach comes with some caveats: + * 1. As soon as any other node is added to the root, the copying behavior won't work anymore. + * A diagnostic will be added to flag cases like this and to explain how to work around it. + * 2. If `preserveWhitespaces` is enabled, it's very likely that indentation will break this + * workaround, because it'll include an additional text node as the first child. We can work + * around it here, but in a discussion it was decided not to, because the user explicitly opted + * into preserving the whitespace and we would have to drop it from the generated code. + * The diagnostic mentioned point #1 will flag such cases to users. + * + * @returns Tag name to be used for the control flow template. + */ +function ingestControlFlowInsertionPoint( + unit: ViewCompilationUnit, xref: ir.XrefId, node: t.IfBlockBranch|t.ForLoopBlock): string|null { + let root: t.Element|t.Template|null = null; + + for (const child of node.children) { + // Skip over comment nodes. + if (child instanceof t.Comment) { + continue; + } + + // We can only infer the tag name/attributes if there's a single root node. + if (root !== null) { + return null; + } + + // Root nodes can only elements or templates with a tag name (e.g. `
    `). + if (child instanceof t.Element || (child instanceof t.Template && child.tagName !== null)) { + root = child; + } + } + + // If we've found a single root node, its tag name and *static* attributes can be copied + // to the surrounding template to be used for content projection. Note that it's important + // that we don't copy any bound attributes since they don't participate in content projection + // and they can be used in directive matching (in the case of `Template.templateAttrs`). + if (root !== null) { + for (const attr of root.attributes) { + ingestBinding( + unit, xref, attr.name, o.literal(attr.value), e.BindingType.Attribute, null, + SecurityContext.NONE, attr.sourceSpan, BindingFlags.TextValue); + } + + return root instanceof t.Element ? root.name : root.tagName; + } + + return null; +} diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 3f6f37abb1d44..043b7be60ade6 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -263,22 +263,23 @@ export function i18nStart(slot: number, constIndex: number, subTemplateIndex: nu } export function repeaterCreate( - slot: number, viewFnName: string, decls: number, vars: number, trackByFn: o.Expression, - trackByUsesComponentInstance: boolean, emptyViewFnName: string|null, emptyDecls: number|null, - emptyVars: number|null, sourceSpan: ParseSourceSpan|null): ir.CreateOp { - let args = [ + slot: number, viewFnName: string, decls: number, vars: number, tag: string|null, + constIndex: number|null, trackByFn: o.Expression, trackByUsesComponentInstance: boolean, + emptyViewFnName: string|null, emptyDecls: number|null, emptyVars: number|null, + sourceSpan: ParseSourceSpan|null): ir.CreateOp { + const args = [ o.literal(slot), o.variable(viewFnName), o.literal(decls), o.literal(vars), + o.literal(tag), + o.literal(constIndex), trackByFn, ]; if (trackByUsesComponentInstance || emptyViewFnName !== null) { args.push(o.literal(trackByUsesComponentInstance)); if (emptyViewFnName !== null) { - args.push(o.variable(emptyViewFnName)); - args.push(o.literal(emptyDecls)); - args.push(o.literal(emptyVars)); + args.push(o.variable(emptyViewFnName), o.literal(emptyDecls), o.literal(emptyVars)); } } return call(Identifiers.repeaterCreate, args, sourceSpan); diff --git a/packages/compiler/src/template/pipeline/src/phases/naming.ts b/packages/compiler/src/template/pipeline/src/phases/naming.ts index e78ab96f92819..00446cc409bd8 100644 --- a/packages/compiler/src/template/pipeline/src/phases/naming.ts +++ b/packages/compiler/src/template/pipeline/src/phases/naming.ts @@ -10,7 +10,6 @@ import {sanitizeIdentifier} from '../../../../parse_util'; import {hyphenate} from '../../../../render3/view/style_parser'; import * as ir from '../../ir'; import {ViewCompilationUnit, type CompilationJob, type CompilationUnit} from '../compilation'; -import {prefixWithNamespace} from '../conversion'; /** * Generate names for functions and variables across all views. @@ -76,17 +75,13 @@ function addNamesToView( const emptyView = unit.job.views.get(op.emptyView)!; // Repeater empty view function is at slot +2 (metadata is in the first slot). addNamesToView( - emptyView, - `${baseName}_${prefixWithNamespace(`${op.tag}Empty`, op.namespace)}_${ - op.slot.slot + 2}`, + emptyView, `${baseName}_${`${op.functionNameSuffix}Empty`}_${op.slot.slot + 2}`, state, compatibility); } - const repeaterToken = - op.tag === null ? '' : '_' + prefixWithNamespace(op.tag, op.namespace); // Repeater primary view function is at slot +1 (metadata is in the first slot). addNamesToView( - unit.job.views.get(op.xref)!, `${baseName}${repeaterToken}_${op.slot.slot + 1}`, state, - compatibility); + unit.job.views.get(op.xref)!, + `${baseName}_${op.functionNameSuffix}_${op.slot.slot + 1}`, state, compatibility); break; case ir.OpKind.Template: if (!(unit instanceof ViewCompilationUnit)) { @@ -96,8 +91,8 @@ function addNamesToView( if (op.slot.slot === null) { throw new Error(`Expected slot to be assigned`); } - const tagToken = op.tag === null ? '' : '_' + prefixWithNamespace(op.tag, op.namespace); - addNamesToView(childView, `${baseName}${tagToken}_${op.slot.slot}`, state, compatibility); + const suffix = op.functionNameSuffix.length === 0 ? '' : `_${op.functionNameSuffix}`; + addNamesToView(childView, `${baseName}${suffix}_${op.slot.slot}`, state, compatibility); break; case ir.OpKind.StyleProp: op.name = normalizeStylePropName(op.name); diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index fc62fba45311d..977bf31f39201 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -98,7 +98,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList Date: Mon, 30 Oct 2023 20:02:36 +0100 Subject: [PATCH 46/52] build: add missing entries for the api extraction (#52452) Were missing : * `@angular/common/http/testing` * `@angular/platform-browser/animations/async` PR Close #52452 --- packages/common/http/testing/BUILD.bazel | 8 ++++++++ packages/platform-browser/animations/async/BUILD.bazel | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/common/http/testing/BUILD.bazel b/packages/common/http/testing/BUILD.bazel index 2b5b5d9f739fa..91e15a534bebe 100644 --- a/packages/common/http/testing/BUILD.bazel +++ b/packages/common/http/testing/BUILD.bazel @@ -1,4 +1,5 @@ load("//tools:defaults.bzl", "ng_module") +load("@npm//@angular/build-tooling/bazel/api-gen:generate_api_docs.bzl", "generate_api_docs") package(default_visibility = ["//visibility:public"]) @@ -26,3 +27,10 @@ filegroup( "src/**/*.ts", ]) + ["PACKAGE.md"], ) + +generate_api_docs( + name = "http_testing_docs", + srcs = [":files_for_docgen"], + entry_point = ":index.ts", + module_name = "@angular/common/http", +) diff --git a/packages/platform-browser/animations/async/BUILD.bazel b/packages/platform-browser/animations/async/BUILD.bazel index db72a327db9b9..bbbebfe4eb900 100644 --- a/packages/platform-browser/animations/async/BUILD.bazel +++ b/packages/platform-browser/animations/async/BUILD.bazel @@ -1,4 +1,5 @@ load("//tools:defaults.bzl", "ng_module", "tsec_test") +load("@npm//@angular/build-tooling/bazel/api-gen:generate_api_docs.bzl", "generate_api_docs") package(default_visibility = ["//visibility:public"]) @@ -34,3 +35,10 @@ filegroup( "src/**/*.ts", ]) + ["PACKAGE.md"], ) + +generate_api_docs( + name = "platform-browser_animations_async_docs", + srcs = [":files_for_docgen"], + entry_point = ":index.ts", + module_name = "@angular/platform-browser/animations", +) From 4df04b654653c305913fa0ec718c1a938fe35cb7 Mon Sep 17 00:00:00 2001 From: AleksanderBodurri Date: Mon, 30 Oct 2023 16:04:59 -0400 Subject: [PATCH 47/52] refactor(core): ignore ENVIRONMENT_INTIIALIZER and INJECTOR_DEF_TYPES provides in getInjectorProviders (#52458) These special providers are configured when `walkProviderTree` is called. Because of this, they do not maintain any equality between subsequent runs of `walkProviderTree`. This prevents us from being able to compare the provider objects for equality between runs. This commit changes the behaviour of getInjectorProviders to ignore these providers. In the future we will consider another approach for differentiating these providers from ones provided by users rather than the framework. PR Close #52458 --- .../render3/util/injector_discovery_utils.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/core/src/render3/util/injector_discovery_utils.ts b/packages/core/src/render3/util/injector_discovery_utils.ts index bb8317c9f762b..327de2c2b5b43 100644 --- a/packages/core/src/render3/util/injector_discovery_utils.ts +++ b/packages/core/src/render3/util/injector_discovery_utils.ts @@ -30,6 +30,9 @@ import {getParentInjectorIndex, getParentInjectorView, hasParentInjector} from ' import {assertTNodeForLView, assertTNode} from '../assert'; import {RElement} from '../interfaces/renderer_dom'; import {getNativeByTNode} from './view_utils'; +import {INJECTOR_DEF_TYPES} from '../../di/internal_tokens'; +import {ENVIRONMENT_INITIALIZER} from '../../di/initializer_token'; +import {ValueProvider} from '../../di/interface/provider'; /** * Discovers the dependencies of an injectable instance. Provides DI information about each @@ -391,12 +394,13 @@ function walkProviderTreeToDiscoverImportPaths( * @returns an array of objects representing the providers of the given injector */ function getEnvironmentInjectorProviders(injector: EnvironmentInjector): ProviderRecord[] { - const providerRecords = getFrameworkDIDebugData().resolverToProviders.get(injector) ?? []; + const providerRecordsWithoutImportPaths = + getFrameworkDIDebugData().resolverToProviders.get(injector) ?? []; // platform injector has no provider imports container so can we skip trying to // find import paths if (isPlatformInjector(injector)) { - return providerRecords; + return providerRecordsWithoutImportPaths; } const providerImportsContainer = getProviderImportsContainer(injector); @@ -407,27 +411,37 @@ function getEnvironmentInjectorProviders(injector: EnvironmentInjector): Provide // container (and thus no concept of module import paths). Therefore we simply // return the provider records as is. if (isRootInjector(injector)) { - return providerRecords; + return providerRecordsWithoutImportPaths; } throwError('Could not determine where injector providers were configured.'); } const providerToPath = getProviderImportPaths(providerImportsContainer); + const providerRecords = []; + + for (const providerRecord of providerRecordsWithoutImportPaths) { + const provider = providerRecord.provider; + // Ignore these special providers for now until we have a cleaner way of + // determing when they are provided by the framework vs provided by the user. + const token = (provider as ValueProvider).provide; + if (token === ENVIRONMENT_INITIALIZER || token === INJECTOR_DEF_TYPES) { + continue; + } - return providerRecords.map(providerRecord => { - let importPath = providerToPath.get(providerRecord.provider) ?? [providerImportsContainer]; + let importPath = providerToPath.get(provider) ?? []; const def = getComponentDef(providerImportsContainer); const isStandaloneComponent = !!def?.standalone; // We prepend the component constructor in the standalone case // because walkProviderTree does not visit this constructor during it's traversal if (isStandaloneComponent) { - importPath = [providerImportsContainer, ...providerToPath.get(providerRecord.provider) ?? []]; + importPath = [providerImportsContainer, ...importPath]; } - return {...providerRecord, importPath}; - }); + providerRecords.push({...providerRecord, importPath}); + } + return providerRecords; } function isPlatformInjector(injector: Injector) { From 4c99eb31aab45bb99d19d72bcb5195d96d420c3c Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 30 Oct 2023 17:13:55 -0400 Subject: [PATCH 48/52] docs: update defer guide to use Deferrable View language (#52457) This updates the defer guide to use the recommended language of Deferrable Views. PR Close #52457 --- aio/content/guide/defer.md | 10 +++++----- aio/content/navigation.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aio/content/guide/defer.md b/aio/content/guide/defer.md index 9d6f6c5ffa8a6..424ebf2aac9f5 100644 --- a/aio/content/guide/defer.md +++ b/aio/content/guide/defer.md @@ -1,10 +1,10 @@ -# Deferred Loading +# Deferrable Views ## Overview -Deferred loading is a feature that can be used in component template to defer the loading of select dependencies within that template. Those dependencies include components, directives, and pipes, and any associated CSS. To use this feature, you can declaratively wrap a section of your template in a `@defer` block which specifies the loading conditions. +Deferrable views can be used in component template to defer the loading of select dependencies within that template. Those dependencies include components, directives, and pipes, and any associated CSS. To use this feature, you can declaratively wrap a section of your template in a `@defer` block which specifies the loading conditions. -The `@defer` block supports a series of [triggers](guide/defer#triggers), [prefeching](guide/defer#prefetching), and several sub blocks used for [placeholder](guide/defer#placeholder), [loading](guide/defer#loading), and [error](guide/defer#error) state management. You can also create custom conditions with [`when`](guide/defer#when) and [`prefetch when`](guide/defer#prefetching). +Deferrable views support a series of [triggers](guide/defer#triggers), [prefeching](guide/defer#prefetching), and several sub blocks used for [placeholder](guide/defer#placeholder), [loading](guide/defer#loading), and [error](guide/defer#error) state management. You can also create custom conditions with [`when`](guide/defer#when) and [`prefetch when`](guide/defer#prefetching). ```html @defer { @@ -12,9 +12,9 @@ The `@defer` block supports a series of [triggers](guide/defer#triggers), [prefe } ``` -## Why use `@defer`? +## Why use Deferrable Views? -`@defer` is a powerful tool that can be used to reduce the initial bundle size of your application or defer heavy components that may not ever be loaded until a later time. This should result in a faster initial load and an improvement in your Core Web Vitals (CWV) results. Deferring some of your components until later should specifically improve Largest Contentful Paint (LCP) and Time to First Byte (TTFB). +Deferrable views, also known as `@defer` blocks, are a powerful tool that can be used to reduce the initial bundle size of your application or defer heavy components that may not ever be loaded until a later time. This should result in a faster initial load and an improvement in your Core Web Vitals (CWV) results. Deferring some of your components until later should specifically improve Largest Contentful Paint (LCP) and Time to First Byte (TTFB). Note: It is highly recommended that any defer loaded component that might result in layout shift once the dependencies have loaded be below the fold or otherwise not yet visible to the user. diff --git a/aio/content/navigation.json b/aio/content/navigation.json index 3c65ce3f79502..7d7a4933e31ab 100644 --- a/aio/content/navigation.json +++ b/aio/content/navigation.json @@ -518,7 +518,7 @@ }, { "url": "guide/defer", - "title": "Deferred Loading", + "title": "Deferrable views", "tooltip": "How to defer load components, directives, and pipes." }, { From f86fb8eb036d62426a6eb4fb3e264c4a1f42d19c Mon Sep 17 00:00:00 2001 From: Alex Castle Date: Mon, 30 Oct 2023 14:25:24 -0700 Subject: [PATCH 49/52] fix(common): apply fixed_srcset_width value only to fixed srcsets (#52459) add logic to NgOptimizedImage to keep fixed_srcset_width from applying to large responsive images, which is incorrect behavior PR Close #52459 --- .../ng_optimized_image/ng_optimized_image.ts | 7 +++- .../directives/ng_optimized_image_spec.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index 4152cbea591f2..6ca58c38605c5 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -513,8 +513,13 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { } private shouldGenerateAutomaticSrcset(): boolean { + let oversizedImage = false; + if (!this.sizes) { + oversizedImage = + this.width! > FIXED_SRCSET_WIDTH_LIMIT || this.height! > FIXED_SRCSET_HEIGHT_LIMIT; + } return !this.disableOptimizedSrcset && !this.srcset && this.imageLoader !== noopImageLoader && - !(this.width! > FIXED_SRCSET_WIDTH_LIMIT || this.height! > FIXED_SRCSET_HEIGHT_LIMIT); + !oversizedImage; } /** @nodoc */ diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 3cd1f03b2012c..302a49c93c7ab 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -1797,6 +1797,38 @@ describe('Image directive', () => { expect(img.getAttribute('srcset')).toBeNull(); }); + it('should add a responsive srcset to the img element if height is too large', () => { + setupTestingModule({imageLoader}); + + const template = ``; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('srcset')) + .toBe(`${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${ + IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${ + IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${ + IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`); + }); + + it('should add a responsive srcset to the img element if width is too large', () => { + setupTestingModule({imageLoader}); + + const template = ``; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('srcset')) + .toBe(`${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${ + IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${ + IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${ + IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`); + }); + it('should use a custom breakpoint set if one is provided', () => { const imageConfig = { breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 1280, 3840], From 8ef4b1d2d1a6e5c53ab886fd68609a38f2fc2769 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Mon, 30 Oct 2023 16:02:59 -0700 Subject: [PATCH 50/52] refactor(compiler): rename decorator extracted "options" to "members" (#52462) The property name `members` makes it easier to use the same rendering code as other constructs. PR Close #52462 --- .../src/ngtsc/docs/src/decorator_extractor.ts | 2 +- .../src/ngtsc/docs/src/entities.ts | 2 +- .../decorator_doc_extraction_spec.ts | 34 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts index 724527f12134b..6cd5e3e5caf67 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/decorator_extractor.ts @@ -29,7 +29,7 @@ export function extractorDecorator( rawComment: extractRawJsDoc(documentedNode), description: extractJsDocDescription(documentedNode), jsdocTags: extractJsDocTags(documentedNode), - options: getDecoratorOptions(declaration, typeChecker), + members: getDecoratorOptions(declaration, typeChecker), }; } diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index 884cfc01d80de..5b64beaebd13b 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -100,7 +100,7 @@ export interface EnumEntry extends DocEntry { /** Documentation entity for an Angular decorator. */ export interface DecoratorEntry extends DocEntry { decoratorType: DecoratorType; - options: PropertyEntry[]; + members: PropertyEntry[]; } /** Documentation entity for an Angular directives and components. */ diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts index f3ea048413194..f0c5e28b5e142 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/decorator_doc_extraction_spec.ts @@ -24,7 +24,7 @@ runInEachFileSystem(() => { env.tsconfig(); }); - it('should extract class decorators that define options in an interface', () => { + it('should extract class decorators that define members in an interface', () => { env.write('index.ts', ` export interface Component { /** The template. */ @@ -50,10 +50,10 @@ runInEachFileSystem(() => { expect(decoratorEntry.entryType).toBe(EntryType.Decorator); expect(decoratorEntry.decoratorType).toBe(DecoratorType.Class); - expect(decoratorEntry.options.length).toBe(1); - expect(decoratorEntry.options[0].name).toBe('template'); - expect(decoratorEntry.options[0].type).toBe('string'); - expect(decoratorEntry.options[0].description).toBe('The template.'); + expect(decoratorEntry.members.length).toBe(1); + expect(decoratorEntry.members[0].name).toBe('template'); + expect(decoratorEntry.members[0].type).toBe('string'); + expect(decoratorEntry.members[0].description).toBe('The template.'); }); it('should extract property decorators', () => { @@ -82,10 +82,10 @@ runInEachFileSystem(() => { expect(decoratorEntry.entryType).toBe(EntryType.Decorator); expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); - expect(decoratorEntry.options.length).toBe(1); - expect(decoratorEntry.options[0].name).toBe('alias'); - expect(decoratorEntry.options[0].type).toBe('string'); - expect(decoratorEntry.options[0].description).toBe('The alias.'); + expect(decoratorEntry.members.length).toBe(1); + expect(decoratorEntry.members[0].name).toBe('alias'); + expect(decoratorEntry.members[0].type).toBe('string'); + expect(decoratorEntry.members[0].description).toBe('The alias.'); }); it('should extract property decorators with a type alias', () => { @@ -116,10 +116,10 @@ runInEachFileSystem(() => { expect(decoratorEntry.entryType).toBe(EntryType.Decorator); expect(decoratorEntry.decoratorType).toBe(DecoratorType.Member); - expect(decoratorEntry.options.length).toBe(1); - expect(decoratorEntry.options[0].name).toBe('read'); - expect(decoratorEntry.options[0].type).toBe('string'); - expect(decoratorEntry.options[0].description).toBe('The read.'); + expect(decoratorEntry.members.length).toBe(1); + expect(decoratorEntry.members[0].name).toBe('read'); + expect(decoratorEntry.members[0].type).toBe('string'); + expect(decoratorEntry.members[0].description).toBe('The read.'); }); it('should extract param decorators', () => { @@ -148,10 +148,10 @@ runInEachFileSystem(() => { expect(decoratorEntry.entryType).toBe(EntryType.Decorator); expect(decoratorEntry.decoratorType).toBe(DecoratorType.Parameter); - expect(decoratorEntry.options.length).toBe(1); - expect(decoratorEntry.options[0].name).toBe('token'); - expect(decoratorEntry.options[0].type).toBe('string'); - expect(decoratorEntry.options[0].description).toBe('The token.'); + expect(decoratorEntry.members.length).toBe(1); + expect(decoratorEntry.members[0].name).toBe('token'); + expect(decoratorEntry.members[0].type).toBe('string'); + expect(decoratorEntry.members[0].description).toBe('The token.'); }); }); }); From 7bb3ffb77f514f0ab67ebbb8b0b4ad376a0d7b38 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 31 Oct 2023 13:23:03 -0700 Subject: [PATCH 51/52] fix(core): add `rejectErrors` option to `toSignal` (#52474) By default, `toSignal` transforms an `Observable` into a `Signal`, including the error channel of the Observable. When an error is received, the signal begins throwing the error. `toSignal` is intended to serve the same purpose as the `async` pipe, but the async pipe has a different behavior with errors: it rejects them outright, throwing them back into RxJS. Rx then propagates the error into the browser's uncaught error handling logic. In the case of Angular, the error is then caught by zone.js and reported via the application's `ErrorHandler`. This commit introduces a new option for `toSignal` called `rejectErrors`. With that flag set, `toSignal` copies the async pipe's behavior, allowing for easier migrations. Fixes #51949 PR Close #52474 --- aio/content/guide/rxjs-interop.md | 6 ++++- goldens/public-api/core/rxjs-interop/index.md | 1 + packages/core/rxjs-interop/src/to_signal.ts | 19 ++++++++++++++- .../core/rxjs-interop/test/to_signal_spec.ts | 24 ++++++++++++++++++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/aio/content/guide/rxjs-interop.md b/aio/content/guide/rxjs-interop.md index 396dcda775ec9..7f14ab76de572 100644 --- a/aio/content/guide/rxjs-interop.md +++ b/aio/content/guide/rxjs-interop.md @@ -59,10 +59,14 @@ The `manualCleanup` option disables this automatic cleanup. You can use this set ### Error and Completion -If an Observable used in `toSignal` produces an error, that error is thrown when the signal is read. +If an Observable used in `toSignal` produces an error, that error is thrown when the signal is read. It's recommended that errors be handled upstream in the Observable and turned into a value instead (which might indicate to the template that an error page needs to be displayed). This can be done using the `catchError` operator in RxJS. If an Observable used in `toSignal` completes, the signal continues to return the most recently emitted value before completion. +#### The `rejectErrors` option + +`toSignal`'s default behavior for errors propagates the error channel of the `Observable` through to the signal. An alternative approach is to reject errors entirely, using the `rejectErrors` option of `toSignal`. With this option, errors are thrown back into RxJS where they'll be trapped as uncaught exceptions in the global application error handler. Since Observables no longer produce values after they error, the signal returned by `toSignal` will keep returning the last successful value received from the Observable forever. This is the same behavior as the `async` pipe has for errors. + ## `toObservable` The `toObservable` utility creates an `Observable` which tracks the value of a signal. The signal's value is monitored with an `effect`, which emits the value to the Observable when it changes. diff --git a/goldens/public-api/core/rxjs-interop/index.md b/goldens/public-api/core/rxjs-interop/index.md index d3bc5fc9373db..57f469b0cd97d 100644 --- a/goldens/public-api/core/rxjs-interop/index.md +++ b/goldens/public-api/core/rxjs-interop/index.md @@ -54,6 +54,7 @@ export interface ToSignalOptions { initialValue?: unknown; injector?: Injector; manualCleanup?: boolean; + rejectErrors?: boolean; requireSync?: boolean; } diff --git a/packages/core/rxjs-interop/src/to_signal.ts b/packages/core/rxjs-interop/src/to_signal.ts index 7510aa58bbf2a..c65cf1857be05 100644 --- a/packages/core/rxjs-interop/src/to_signal.ts +++ b/packages/core/rxjs-interop/src/to_signal.ts @@ -48,6 +48,16 @@ export interface ToSignalOptions { * until the `Observable` itself completes. */ manualCleanup?: boolean; + + /** + * Whether `toSignal` should throw errors from the Observable error channel back to RxJS, where + * they'll be processed as uncaught exceptions. + * + * In practice, this means that the signal returned by `toSignal` will keep returning the last + * good value forever, as Observables which error produce no further values. This option emulates + * the behavior of the `async` pipe. + */ + rejectErrors?: boolean; } // Base case: no options -> `undefined` in the result type. @@ -126,7 +136,14 @@ export function toSignal( // https://github.com/angular/angular/pull/50522. const sub = source.subscribe({ next: value => state.set({kind: StateKind.Value, value}), - error: error => state.set({kind: StateKind.Error, error}), + error: error => { + if (options?.rejectErrors) { + // Kick the error back to RxJS. It will be caught and rethrown in a macrotask, which causes + // the error to end up as an uncaught exception. + throw error; + } + state.set({kind: StateKind.Error, error}); + }, // Completion of the Observable is meaningless to the signal. Signals don't have a concept of // "complete". }); diff --git a/packages/core/rxjs-interop/test/to_signal_spec.ts b/packages/core/rxjs-interop/test/to_signal_spec.ts index 38496e5a6df3c..423323f30619f 100644 --- a/packages/core/rxjs-interop/test/to_signal_spec.ts +++ b/packages/core/rxjs-interop/test/to_signal_spec.ts @@ -9,7 +9,7 @@ import {ChangeDetectionStrategy, Component, computed, EnvironmentInjector, Injector, runInInjectionContext, Signal} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {TestBed} from '@angular/core/testing'; -import {BehaviorSubject, Observable, ReplaySubject, Subject} from 'rxjs'; +import {BehaviorSubject, Observable, Observer, ReplaySubject, Subject, Subscribable, Unsubscribable} from 'rxjs'; describe('toSignal()', () => { it('should reflect the last emitted value of an Observable', test(() => { @@ -122,6 +122,28 @@ describe('toSignal()', () => { /toSignal\(\) cannot be called from within a reactive context. Invoking `toSignal` causes new subscriptions every time./); }); + it('should throw the error back to RxJS if rejectErrors is set', () => { + let capturedObserver: Observer = null!; + const fake$ = { + subscribe(observer: Observer): Unsubscribable { + capturedObserver = observer; + return {unsubscribe(): void {}}; + }, + } as Subscribable; + + const s = toSignal(fake$, {initialValue: 0, rejectErrors: true, manualCleanup: true}); + expect(s()).toBe(0); + if (capturedObserver === null) { + return fail('Observer not captured as expected.'); + } + + capturedObserver.next(1); + expect(s()).toBe(1); + + expect(() => capturedObserver.error('test')).toThrow('test'); + expect(s()).toBe(1); + }); + describe('with no initial value', () => { it('should return `undefined` if read before a value is emitted', test(() => { const counter$ = new Subject(); From de802f0c1a566222beb9a76e24c6de6a1a886e72 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 31 Oct 2023 16:16:16 -0700 Subject: [PATCH 52/52] Revert "fix(http): Don't override the backend when using the InMemoryWebAPI (#52425)" This reverts commit 49b037f8116d56520035a1cc0797b65f1b0e3ad9. Reason: it breaks tests in aio-local. --- packages/common/http/src/private_export.ts | 2 +- .../http-client-in-memory-web-api-module.ts | 23 +++++++++---- .../src/in-memory-web-api-module.ts | 15 +++++---- .../test/http-client-backend-service_spec.ts | 32 ++----------------- 4 files changed, 27 insertions(+), 45 deletions(-) diff --git a/packages/common/http/src/private_export.ts b/packages/common/http/src/private_export.ts index bc0c9866932a2..d5b0ff7cb9f87 100644 --- a/packages/common/http/src/private_export.ts +++ b/packages/common/http/src/private_export.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ -export {HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS, PRIMARY_HTTP_BACKEND as ɵPRIMARY_HTTP_BACKEND} from './interceptor'; +export {HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS} from './interceptor'; diff --git a/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts b/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts index 8b32b61bbc584..e2fe53ea15ca4 100644 --- a/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts +++ b/packages/misc/angular-in-memory-web-api/src/http-client-in-memory-web-api-module.ts @@ -6,12 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {HttpBackend, ɵPRIMARY_HTTP_BACKEND as PRIMARY_HTTP_BACKEND} from '@angular/common/http'; +import {XhrFactory} from '@angular/common'; +import {HttpBackend} from '@angular/common/http'; import {ModuleWithProviders, NgModule, Type} from '@angular/core'; import {HttpClientBackendService} from './http-client-backend-service'; import {InMemoryBackendConfig, InMemoryBackendConfigArgs, InMemoryDbService} from './interfaces'; +// Internal - Creates the in-mem backend for the HttpClient module +// AoT requires factory to be exported +export function httpClientInMemBackendServiceFactory( + dbService: InMemoryDbService, options: InMemoryBackendConfig, + xhrFactory: XhrFactory): HttpBackend { + return new HttpClientBackendService(dbService, options, xhrFactory) as HttpBackend; +} + @NgModule() export class HttpClientInMemoryWebApiModule { /** @@ -22,8 +31,6 @@ export class HttpClientInMemoryWebApiModule { * Usually imported in the root application module. * Can import in a lazy feature module too, which will shadow modules loaded earlier * - * Note: If you use the `FetchBackend`, make sure forRoot is invoked after in the providers list - * * @param dbCreator - Class that creates seed data for in-memory database. Must implement * InMemoryDbService. * @param [options] @@ -37,10 +44,12 @@ export class HttpClientInMemoryWebApiModule { return { ngModule: HttpClientInMemoryWebApiModule, providers: [ - HttpClientBackendService, {provide: InMemoryDbService, useClass: dbCreator}, - {provide: InMemoryBackendConfig, useValue: options}, - {provide: HttpBackend, useExisting: HttpClientBackendService}, - {provide: PRIMARY_HTTP_BACKEND, useExisting: HttpClientBackendService} + {provide: InMemoryDbService, useClass: dbCreator}, + {provide: InMemoryBackendConfig, useValue: options}, { + provide: HttpBackend, + useFactory: httpClientInMemBackendServiceFactory, + deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory] + } ] }; } diff --git a/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts b/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts index 21030dbc67c88..6234aa513bfc2 100644 --- a/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts +++ b/packages/misc/angular-in-memory-web-api/src/in-memory-web-api-module.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {HttpBackend, ɵPRIMARY_HTTP_BACKEND as PRIMARY_HTTP_BACKEND} from '@angular/common/http'; +import {XhrFactory} from '@angular/common'; +import {HttpBackend} from '@angular/common/http'; import {ModuleWithProviders, NgModule, Type} from '@angular/core'; -import {HttpClientBackendService} from './http-client-backend-service'; +import {httpClientInMemBackendServiceFactory} from './http-client-in-memory-web-api-module'; import {InMemoryBackendConfig, InMemoryBackendConfigArgs, InMemoryDbService} from './interfaces'; @NgModule() @@ -22,8 +23,6 @@ export class InMemoryWebApiModule { * Usually imported in the root application module. * Can import in a lazy feature module too, which will shadow modules loaded earlier * - * Note: If you use the `FetchBackend`, make sure forRoot is invoked after in the providers list - * * @param dbCreator - Class that creates seed data for in-memory database. Must implement * InMemoryDbService. * @param [options] @@ -38,9 +37,11 @@ export class InMemoryWebApiModule { ngModule: InMemoryWebApiModule, providers: [ {provide: InMemoryDbService, useClass: dbCreator}, - {provide: InMemoryBackendConfig, useValue: options}, - {provide: HttpBackend, useClass: HttpClientBackendService}, - {provide: PRIMARY_HTTP_BACKEND, useExisting: HttpClientBackendService} + {provide: InMemoryBackendConfig, useValue: options}, { + provide: HttpBackend, + useFactory: httpClientInMemBackendServiceFactory, + deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory] + } ] }; } diff --git a/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts b/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts index 0d6dab79ad73d..d857fb969611a 100644 --- a/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts +++ b/packages/misc/angular-in-memory-web-api/test/http-client-backend-service_spec.ts @@ -8,8 +8,8 @@ import 'jasmine-ajax'; -import {FetchBackend, HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpClientModule, HttpEvent, HttpEventType, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, provideHttpClient, withFetch} from '@angular/common/http'; -import {importProvidersFrom, Injectable} from '@angular/core'; +import {HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpClientModule, HttpEvent, HttpEventType, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http'; +import {Injectable} from '@angular/core'; import {TestBed, waitForAsync} from '@angular/core/testing'; import {HttpClientBackendService, HttpClientInMemoryWebApiModule} from 'angular-in-memory-web-api'; import {Observable, zip} from 'rxjs'; @@ -565,34 +565,6 @@ describe('HttpClient Backend Service', () => { failRequest); })); }); - - describe('when using the FetchBackend', () => { - it('should be the an InMemory Service', () => { - TestBed.configureTestingModule({ - providers: [ - provideHttpClient(withFetch()), - importProvidersFrom( - HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay})), - {provide: HeroService, useClass: HttpClientHeroService} - ] - }); - - expect(TestBed.inject(HttpBackend)).toBeInstanceOf(HttpClientBackendService); - }); - - it('should be a FetchBackend', () => { - // In this test, providers order matters - TestBed.configureTestingModule({ - providers: [ - importProvidersFrom( - HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay})), - provideHttpClient(withFetch()), {provide: HeroService, useClass: HttpClientHeroService} - ] - }); - - expect(TestBed.inject(HttpBackend)).toBeInstanceOf(FetchBackend); - }); - }); });