Skip to content

Commit

Permalink
feat(core): implement deferred block interaction triggers
Browse files Browse the repository at this point in the history
Adds the implementation for the `on interaction` and `prefetch on interaction` triggers.
  • Loading branch information
crisbeto committed Sep 22, 2023
1 parent e47f0bc commit 7a1ce37
Show file tree
Hide file tree
Showing 6 changed files with 710 additions and 23 deletions.
145 changes: 139 additions & 6 deletions packages/core/src/render3/instructions/defer.ts
Expand Up @@ -9,21 +9,23 @@
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';
import {DEFER_BLOCK_STATE, DeferBlockBehavior, DeferBlockConfig, DeferBlockInternalState, DeferBlockState, DeferBlockTriggers, DeferDependenciesLoadingState, DeferredLoadingBlockConfig, DeferredPlaceholderBlockConfig, DependencyResolverFn, LDeferBlockDetails, TDeferBlockDetails} from '../interfaces/defer';
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';

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
*
Expand Down
75 changes: 75 additions & 0 deletions 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<Element, DeferEventEntry>();

/** 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);
}
}
};
}
14 changes: 1 addition & 13 deletions packages/core/src/render3/state.ts
Expand Up @@ -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';


/**
Expand Down Expand Up @@ -699,18 +699,6 @@ export function nextContextImpl<T = any>(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.
*
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/render3/util/view_utils.ts
Expand Up @@ -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';



Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/util/assert.ts
Expand Up @@ -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;
Expand Down

0 comments on commit 7a1ce37

Please sign in to comment.