From 7a1ce379cf7393da796a9ebb0db2aef96483d2b6 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 22 Sep 2023 19:11:46 +0200 Subject: [PATCH] feat(core): implement deferred block interaction triggers Adds the implementation for the `on interaction` and `prefetch on interaction` triggers. --- .../core/src/render3/instructions/defer.ts | 145 +++++- .../src/render3/instructions/defer_events.ts | 75 +++ packages/core/src/render3/state.ts | 14 +- packages/core/src/render3/util/view_utils.ts | 22 +- packages/core/src/util/assert.ts | 6 + packages/core/test/acceptance/defer_spec.ts | 471 +++++++++++++++++- 6 files changed, 710 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/render3/instructions/defer_events.ts diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index e79a776d426ae..56269a742955c 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -9,8 +9,9 @@ import {InjectionToken, Injector, ɵɵdefineInjectable} from '../../di'; import {findMatchingDehydratedView} from '../../hydration/views'; import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref'; -import {assertDefined, assertEqual, throwError} from '../../util/assert'; -import {assertIndexInDeclRange, assertLContainer, assertTNodeForLView} from '../assert'; +import {assertDefined, assertElement, assertEqual, throwError} from '../../util/assert'; +import {afterRender} from '../after_render_hooks'; +import {assertIndexInDeclRange, assertLContainer, assertLView, assertTNodeForLView} from '../assert'; import {bindingUpdated} from '../bindings'; import {getComponentDef, getDirectiveDef, getPipeDef} from '../definition'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../interfaces/container'; @@ -18,12 +19,13 @@ import {DEFER_BLOCK_STATE, DeferBlockBehavior, DeferBlockConfig, DeferBlockInter import {DirectiveDefList, PipeDefList} from '../interfaces/definition'; import {TContainerNode, TNode} from '../interfaces/node'; import {isDestroyed, isLContainer, isLView} from '../interfaces/type_checks'; -import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../interfaces/view'; +import {FLAGS, HEADER_OFFSET, INJECTOR, LView, LViewFlags, PARENT, TVIEW, TView} from '../interfaces/view'; import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state'; import {isPlatformBrowser} from '../util/misc_utils'; -import {getConstant, getTNode, removeLViewOnDestroy, storeLViewOnDestroy, walkUpViews} from '../util/view_utils'; +import {getConstant, getNativeByIndex, getTNode, removeLViewOnDestroy, storeLViewOnDestroy, walkUpViews} from '../util/view_utils'; import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer, shouldAddViewToDom} from '../view_manipulation'; +import {onInteraction} from './defer_events'; import {ɵɵtemplate} from './template'; /** @@ -245,7 +247,14 @@ export function ɵɵdeferPrefetchOnHover() {} // TODO: implement runtime logic. * @codeGenApi */ export function ɵɵdeferOnInteraction(triggerIndex: number, walkUpTimes?: number) { -} // TODO: implement runtime logic. + const lView = getLView(); + const tNode = getCurrentTNode()!; + + renderPlaceholder(lView, tNode); + registerDomTrigger( + lView, tNode, triggerIndex, walkUpTimes, onInteraction, + () => triggerDeferBlock(lView, tNode)); +} /** * Creates runtime data structures for the `prefetch on interaction` deferred trigger. @@ -254,7 +263,17 @@ export function ɵɵdeferOnInteraction(triggerIndex: number, walkUpTimes?: numbe * @codeGenApi */ export function ɵɵdeferPrefetchOnInteraction(triggerIndex: number, walkUpTimes?: number) { -} // TODO: implement runtime logic. + const lView = getLView(); + const tNode = getCurrentTNode()!; + const tView = lView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) { + registerDomTrigger( + lView, tNode, triggerIndex, walkUpTimes, onInteraction, + () => triggerPrefetching(tDetails, lView)); + } +} /** * Creates runtime data structures for the `on viewport` deferred trigger. @@ -272,6 +291,120 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple /********** Helper functions **********/ +/** + * Helper function to get the LView in which a deferred block's trigger is rendered. + * @param deferredHostLView LView in which the deferred block is defined. + * @param deferredTNode TNode defining the deferred block. + * @param walkUpTimes Number of times to go up in the view hierarchy to find the trigger's view. + * A negative value means that the trigger is inside the block's placeholder, while an undefined + * value means that the trigger is in the same LView as the deferred block. + */ +function getTriggerLView( + deferredHostLView: LView, deferredTNode: TNode, walkUpTimes: number|undefined): LView|null { + // The trigger is in the same view, we don't need to traverse. + if (walkUpTimes == null) { + return deferredHostLView; + } + + // A positive value or zero means that the trigger is in a parent view. + if (walkUpTimes >= 0) { + return walkUpViews(walkUpTimes, deferredHostLView); + } + + // If the value is negative, it means that the trigger is inside the placeholder. + const deferredContainer = deferredHostLView[deferredTNode.index]; + ngDevMode && assertLContainer(deferredContainer); + const triggerLView = deferredContainer[CONTAINER_HEADER_OFFSET] ?? null; + + // We need to null check, because the placeholder might not have been rendered yet. + if (ngDevMode && triggerLView !== null) { + const lDetails = getLDeferBlockDetails(deferredHostLView, deferredTNode); + const renderedState = lDetails[DEFER_BLOCK_STATE]; + assertEqual( + renderedState, DeferBlockState.Placeholder, + 'Expected a placeholder to be rendered in this defer block.'); + assertLView(triggerLView); + } + + return triggerLView; +} + +/** + * Gets the element that a deferred block's trigger is pointing to. + * @param triggerLView LView in which the trigger is defined. + * @param triggerIndex Index at which the trigger element should've been rendered. + */ +function getTriggerElement(triggerLView: LView, triggerIndex: number): Element { + const element = getNativeByIndex(HEADER_OFFSET + triggerIndex, triggerLView); + ngDevMode && assertElement(element); + return element as Element; +} + +/** + * Registers a DOM-node based trigger. + * @param initialLView LView in which the defer block is rendered. + * @param tNode TNode representing the defer block. + * @param triggerIndex Index at which to find the trigger element. + * @param walkUpTimes Number of times to go up/down in the view hierarchy to find the trigger. + * @param registerFn Function that will register the DOM events. + * @param callback Callback to be invoked when the trigger receives the event that should render + * the deferred block. + */ +function registerDomTrigger( + initialLView: LView, tNode: TNode, triggerIndex: number, walkUpTimes: number|undefined, + registerFn: (element: Element, callback: VoidFunction, injector: Injector) => VoidFunction, + callback: VoidFunction) { + const injector = initialLView[INJECTOR]!; + + // Assumption: the `afterRender` reference should be destroyed + // automatically so we don't need to keep track of it. + const afterRenderRef = afterRender(() => { + const lDetails = getLDeferBlockDetails(initialLView, tNode); + const renderedState = lDetails[DEFER_BLOCK_STATE]; + + // If the block was loaded before the trigger was resolved, we don't need to do anything. + if (renderedState !== DeferBlockInternalState.Initial && + renderedState !== DeferBlockState.Placeholder) { + afterRenderRef.destroy(); + return; + } + + const triggerLView = getTriggerLView(initialLView, tNode, walkUpTimes); + + // Keep polling until we resolve the trigger's LView. + // `afterRender` should stop automatically if the view is destroyed. + if (!triggerLView) { + return; + } + + // It's possible that the trigger's view was destroyed before we resolved the trigger element. + if (triggerLView[FLAGS] & LViewFlags.Destroyed) { + afterRenderRef.destroy(); + return; + } + + // TODO: add integration with `DeferBlockCleanupManager`. + const element = getTriggerElement(triggerLView, triggerIndex); + const cleanup = registerFn(element, () => { + callback(); + removeLViewOnDestroy(triggerLView, cleanup); + if (initialLView !== triggerLView) { + removeLViewOnDestroy(initialLView, cleanup); + } + cleanup(); + }, injector); + + afterRenderRef.destroy(); + storeLViewOnDestroy(triggerLView, cleanup); + + // Since the trigger and deferred block might be in different + // views, we have to register the callback in both locations. + if (initialLView !== triggerLView) { + storeLViewOnDestroy(initialLView, cleanup); + } + }, {injector}); +} + /** * Helper function to schedule a callback to be invoked when a browser becomes idle. * diff --git a/packages/core/src/render3/instructions/defer_events.ts b/packages/core/src/render3/instructions/defer_events.ts new file mode 100644 index 0000000000000..1b33ee63ec718 --- /dev/null +++ b/packages/core/src/render3/instructions/defer_events.ts @@ -0,0 +1,75 @@ +/*! + * @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 + */ + +/** Configuration object used to register passive and capturing events. */ +const eventListenerOptions: AddEventListenerOptions = { + passive: true, + capture: true +}; + +/** Keeps track of the currently-registered `on interaction` triggers. */ +const interactionTriggers = new WeakMap(); + +/** Names of the events considered as interaction events. */ +const interactionEventNames = ['click', 'keydown'] as const; + +/** Object keeping track of registered callbacks for a deferred block trigger. */ +class DeferEventEntry { + callbacks = new Set<() => void>(); + + listener = () => { + for (const callback of this.callbacks) { + callback(); + } + } +} + +/** + * Registers an interaction trigger. + * @param trigger Element that is the trigger. + * @param callback Callback to be invoked when the trigger is interacted with. + */ +export function onInteraction(trigger: Element, callback: VoidFunction) { + let entry = interactionTriggers.get(trigger); + + // If this is the first entry for this element, add the listeners. + if (!entry) { + // Note that using managing events centrally like this lends itself well to using global + // event delegation. It currently does delegation at the element level, rather than the + // document level, because: + // 1. Global delegation is the most effective when there are a lot of events being registered + // at the same time. Deferred blocks are unlikely to be used in such a way. + // 2. Matching events to their target isn't free. For each `click` and `keydown` event we + // would have look through all the triggers and check if the target either is the element + // itself or it's contained within the element. Given that `click` and `keydown` are some + // of the most common events, this may end up introducing a lot of runtime overhead. + // 3. We're still registering only two events per element, no matter how many deferred blocks + // are referencing it. + entry = new DeferEventEntry(); + interactionTriggers.set(trigger, entry); + + for (const name of interactionEventNames) { + trigger.addEventListener(name, entry.listener, eventListenerOptions); + } + } + + entry.callbacks.add(callback); + + return () => { + const {callbacks, listener} = entry!; + callbacks.delete(callback); + + if (callbacks.size === 0) { + interactionTriggers.delete(trigger); + + for (const name of interactionEventNames) { + trigger.removeEventListener(name, listener, eventListenerOptions); + } + } + }; +} diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 9d06b5a2c9ff7..8edad61cd3794 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -14,7 +14,7 @@ import {DirectiveDef} from './interfaces/definition'; import {TNode, TNodeType} from './interfaces/node'; import {CONTEXT, DECLARATION_VIEW, HEADER_OFFSET, LView, OpaqueViewState, T_HOST, TData, TVIEW, TView, TViewType} from './interfaces/view'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; -import {getTNode} from './util/view_utils'; +import {getTNode, walkUpViews} from './util/view_utils'; /** @@ -699,18 +699,6 @@ export function nextContextImpl(level: number): T { return contextLView[CONTEXT] as unknown as T; } -function walkUpViews(nestingLevel: number, currentView: LView): LView { - while (nestingLevel > 0) { - ngDevMode && - assertDefined( - currentView[DECLARATION_VIEW], - 'Declaration view should be defined if nesting level is greater than 0.'); - currentView = currentView[DECLARATION_VIEW]!; - nestingLevel--; - } - return currentView; -} - /** * Gets the currently selected element index. * diff --git a/packages/core/src/render3/util/view_utils.ts b/packages/core/src/render3/util/view_utils.ts index e4e3b21f454a1..c114d5385d6e0 100644 --- a/packages/core/src/render3/util/view_utils.ts +++ b/packages/core/src/render3/util/view_utils.ts @@ -7,13 +7,13 @@ */ import {RuntimeError, RuntimeErrorCode} from '../../errors'; -import {assertGreaterThan, assertGreaterThanOrEqual, assertIndexInRange, assertLessThan} from '../../util/assert'; +import {assertDefined, assertGreaterThan, assertGreaterThanOrEqual, assertIndexInRange, assertLessThan} from '../../util/assert'; import {assertTNode, assertTNodeForLView} from '../assert'; import {LContainer, TYPE} from '../interfaces/container'; import {TConstants, TNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {isLContainer, isLView} from '../interfaces/type_checks'; -import {DESCENDANT_VIEWS_TO_REFRESH, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, TData, TView} from '../interfaces/view'; +import {DECLARATION_VIEW, DESCENDANT_VIEWS_TO_REFRESH, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, TData, TView} from '../interfaces/view'; @@ -186,6 +186,24 @@ export function clearViewRefreshFlag(lView: LView) { } } +/** + * Walks up the LView hierarchy. + * @param nestingLevel Number of times to walk up in hierarchy. + * @param currentView View from which to start the lookup. + */ +export function walkUpViews(nestingLevel: number, currentView: LView): LView { + while (nestingLevel > 0) { + ngDevMode && + assertDefined( + currentView[DECLARATION_VIEW], + 'Declaration view should be defined if nesting level is greater than 0.'); + currentView = currentView[DECLARATION_VIEW]!; + nestingLevel--; + } + return currentView; +} + + /** * Updates the `DESCENDANT_VIEWS_TO_REFRESH` counter on the parents of the `LView` as well as the * parents above that whose diff --git a/packages/core/src/util/assert.ts b/packages/core/src/util/assert.ts index 3b89a4f5097b5..57d8ae486dd8b 100644 --- a/packages/core/src/util/assert.ts +++ b/packages/core/src/util/assert.ts @@ -112,6 +112,12 @@ export function assertDomNode(node: any): asserts node is Node { } } +export function assertElement(node: any): asserts node is Element { + if (!(node instanceof Element)) { + throwError(`The provided value must be an element but got ${stringify(node)}`); + } +} + export function assertIndexInRange(arr: any[], index: number) { assertDefined(arr, 'Array must be defined.'); const maxLen = arr.length; diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 797e3b3fcad0c..55da4bef7881e 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -10,7 +10,7 @@ import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common'; import {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; import {Component, Input, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; import {getComponentDef} from '@angular/core/src/render3/definition'; -import {DeferBlockBehavior, TestBed} from '@angular/core/testing'; +import {DeferBlockBehavior, fakeAsync, flush, TestBed} from '@angular/core/testing'; /** * Clears all associated directive defs from a given component class. @@ -49,7 +49,7 @@ function onIdle(callback: () => Promise): Promise { const COMMON_PROVIDERS = [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}]; describe('#defer', () => { - beforeEach(() => setEnabledBlockTypes(['defer', 'for'])); + beforeEach(() => setEnabledBlockTypes(['defer', 'for', 'if'])); afterEach(() => setEnabledBlockTypes([])); beforeEach(() => { @@ -1009,4 +1009,471 @@ describe('#defer', () => { }); }); }); + + // Note: these cases specifically use `on interaction`, however + // the resolution logic is the same for all triggers. + describe('trigger resolution', () => { + it('should resolve a trigger is outside the defer block', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + +
+
+
+ +
+
+
+ ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger on a component outside the defer block', fakeAsync(() => { + @Component({selector: 'some-comp', template: '', standalone: true}) + class SomeComp { + } + + @Component({ + standalone: true, + imports: [SomeComp], + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + +
+
+
+ +
+
+
+ ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger that is on a parent element', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger that is inside a parent embedded view', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#if cond} + + + {#if cond} + {#if cond} + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + {/if} + {/if} + {/if} + ` + }) + class MyCmp { + cond = true; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger that is on a component in a parent embedded view', + fakeAsync(() => { + @Component({selector: 'some-comp', template: '', standalone: true}) + class SomeComp { + } + + @Component({ + standalone: true, + imports: [SomeComp], + template: ` + {#if cond} + + + {#if cond} + {#if cond} + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + {/if} + {/if} + {/if} + ` + }) + class MyCmp { + cond = true; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger that is inside the placeholder', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder
+ {/defer} + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should resolve a trigger that is a component inside the placeholder', fakeAsync(() => { + @Component({selector: 'some-comp', template: '', standalone: true}) + class SomeComp { + } + + @Component({ + standalone: true, + imports: [SomeComp], + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder
+ {/defer} + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + }); + + describe('interaction triggers', () => { + it('should load the deferred content when the trigger is clicked', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + + + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should load the deferred content when the trigger receives a keyboard event', + fakeAsync(() => { + // Domino doesn't support creating custom events so we have to skip this test. + if (!isBrowser) { + return; + } + + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + + + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + button.dispatchEvent(new Event('keydown')); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should load the deferred content if a child of the trigger is clicked', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content + {:placeholder} Placeholder + {/defer} + +
+
+ +
+
+ ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + + it('should support multiple deferred blocks with the same trigger', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)} + Main content 1 + {:placeholder}Placeholder 1 + {/defer} + + {#defer on interaction(trigger)} + Main content 2 + {:placeholder}Placeholder 2 + {/defer} + + + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder 1 Placeholder 2'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content 1 Main content 2'); + })); + + it('should unbind the trigger events when the deferred block is loaded', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#defer on interaction(trigger)}Main content{/defer} + + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + const spy = spyOn(button, 'removeEventListener'); + + button.click(); + fixture.detectChanges(); + flush(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Object)); + expect(spy).toHaveBeenCalledWith('keydown', jasmine.any(Function), jasmine.any(Object)); + })); + + it('should unbind the trigger events when the trigger is destroyed', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#if renderBlock} + {#defer on interaction(trigger)}Main content{/defer} + + {/if} + ` + }) + class MyCmp { + renderBlock = true; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + const spy = spyOn(button, 'removeEventListener'); + + fixture.componentInstance.renderBlock = false; + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Object)); + expect(spy).toHaveBeenCalledWith('keydown', jasmine.any(Function), jasmine.any(Object)); + })); + + it('should unbind the trigger events when the deferred block is destroyed', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + {#if renderBlock} + {#defer on interaction(trigger)}Main content{/defer} + {/if} + + + ` + }) + class MyCmp { + renderBlock = true; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + const spy = spyOn(button, 'removeEventListener'); + + fixture.componentInstance.renderBlock = false; + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Object)); + expect(spy).toHaveBeenCalledWith('keydown', jasmine.any(Function), jasmine.any(Object)); + })); + + it('should prefetch resources on interaction', fakeAsync(() => { + @Component({ + standalone: true, + selector: 'root-app', + template: ` + {#defer when isLoaded; prefetch on interaction(trigger)}Main content{/defer} + + ` + }) + class MyCmp { + // We need a `when` trigger here so that `on idle` doesn't get added automatically. + readonly isLoaded = false; + } + + let loadingFnInvokedTimes = 0; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, + useValue: { + intercept: () => () => { + loadingFnInvokedTimes++; + return []; + } + } + }, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(MyCmp); + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(loadingFnInvokedTimes).toBe(0); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + + expect(loadingFnInvokedTimes).toBe(1); + })); + }); });