diff --git a/packages/core/primitives/defer/src/triggers.ts b/packages/core/primitives/defer/src/triggers.ts new file mode 100644 index 00000000000..a212f783b5f --- /dev/null +++ b/packages/core/primitives/defer/src/triggers.ts @@ -0,0 +1,244 @@ + +/** Configuration object used to register passive and capturing events. */ +const eventListenerOptions: AddEventListenerOptions = { + passive: true, + capture: true, +}; + +/** Keeps track of the currently-registered `on hover` triggers. */ +const hoverTriggers = new WeakMap(); + +/** Keeps track of the currently-registered `on interaction` triggers. */ +const interactionTriggers = new WeakMap(); + +/** Currently-registered `viewport` triggers. */ +const viewportTriggers = new WeakMap(); + +/** Names of the events considered as interaction events. */ +export const interactionEventNames = ['click', 'keydown'] as const; + +/** Names of the events considered as hover events. */ +export const hoverEventNames = ['mouseenter', 'mouseover', 'focusin'] as const; + +/** `IntersectionObserver` used to observe `viewport` triggers. */ +let intersectionObserver: IntersectionObserver | null = null; + +/** Number of elements currently observed with `viewport` triggers. */ +let observedViewportElements = 0; + +/** Object keeping track of registered callbacks for a deferred block trigger. */ +class DeferEventEntry { + callbacks = new Set(); + + listener = () => { + for (const callback of this.callbacks) { + callback(); + } + }; +} + +export function getViewportTriggers() { + return viewportTriggers; +} + +/** + * 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): VoidFunction { + let entry = interactionTriggers.get(trigger); + + // If this is the first entry for this element, add the listeners. + if (!entry) { + // Note that 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); + } + } + }; +} + + +/** + * Registers a hover trigger. + * @param trigger Element that is the trigger. + * @param callback Callback to be invoked when the trigger is hovered over. + */ +export function onHover(trigger: Element, callback: VoidFunction): VoidFunction { + let entry = hoverTriggers.get(trigger); + + // If this is the first entry for this element, add the listener. + if (!entry) { + entry = new DeferEventEntry(); + hoverTriggers.set(trigger, entry); + + for (const name of hoverEventNames) { + trigger.addEventListener(name, entry!.listener, eventListenerOptions); + } + } + + entry.callbacks.add(callback); + + return () => { + const {callbacks, listener} = entry!; + callbacks.delete(callback); + + if (callbacks.size === 0) { + for (const name of hoverEventNames) { + trigger.removeEventListener(name, listener, eventListenerOptions); + } + hoverTriggers.delete(trigger); + } + }; +} + +export interface Observer { + observe: (target: Element) => void, + unobserve: (target: Element) => void, + disconnect: () => void; +} + +/** + * Registers a viewport trigger. + * @param trigger Element that is the trigger. + * @param callback Callback to be invoked when the trigger comes into the viewport. + * @param observer Observer interface which provides a way to observe changes to target element + */ +export function onViewport( + trigger: Element, + callback: VoidFunction, + observer: Observer, +): VoidFunction { + let entry = viewportTriggers.get(trigger); + + if (!entry) { + entry = new DeferEventEntry(); + observer.observe(trigger); + viewportTriggers.set(trigger, entry); + observedViewportElements++; + } + + entry.callbacks.add(callback); + + return () => { + // It's possible that a different cleanup callback fully removed this element already. + if (!viewportTriggers.has(trigger)) { + return; + } + + entry!.callbacks.delete(callback); + + if (entry!.callbacks.size === 0) { + observer.unobserve(trigger); + viewportTriggers.delete(trigger); + observedViewportElements--; + } + + if (observedViewportElements === 0) { + observer.disconnect(); + } + }; +} + + +// function createIntersectionObserver(ngZone: NgZone | undefined): IntersectionObserver { +// return new IntersectionObserver((entries) => { +// for (const current of entries) { +// // Only invoke the callbacks if the specific element is intersecting. +// if (current.isIntersecting && viewportTriggers.has(current.target)) { +// if (ngZone) { +// ngZone!.run(viewportTriggers.get(current.target)!.listener); +// } else { +// viewportTriggers.get(current.target)!.listener(); +// } +// } +// } +// }) +// } + +// /** +// * Registers a viewport trigger. +// * @param trigger Element that is the trigger. +// * @param callback Callback to be invoked when the trigger comes into the viewport. +// * @param injector Injector that can be used by the trigger to resolve DI tokens. +// */ +// export function onViewport( +// trigger: Element, +// callback: VoidFunction, +// injector?: Injector, +// ): VoidFunction { +// const ngZone = injector?.get(NgZone); +// let entry = viewportTriggers.get(trigger); + +// if (!intersectionObserver) { +// if (injector) { +// intersectionObserver = ngZone!.runOutsideAngular(() => { +// return createIntersectionObserver(ngZone); +// }); +// } else { +// intersectionObserver = createIntersectionObserver(ngZone); +// } +// } + +// if (!entry) { +// entry = new DeferEventEntry(); +// if (ngZone) { +// ngZone.runOutsideAngular(() => intersectionObserver!.observe(trigger)); +// } else { +// intersectionObserver!.observe(trigger); +// } +// viewportTriggers.set(trigger, entry); +// observedViewportElements++; +// } + +// entry.callbacks.add(callback); + +// return () => { +// // It's possible that a different cleanup callback fully removed this element already. +// if (!viewportTriggers.has(trigger)) { +// return; +// } + +// entry!.callbacks.delete(callback); + +// if (entry!.callbacks.size === 0) { +// intersectionObserver?.unobserve(trigger); +// viewportTriggers.delete(trigger); +// observedViewportElements--; +// } + +// if (observedViewportElements === 0) { +// intersectionObserver?.disconnect(); +// intersectionObserver = null; +// } +// }; +// } + diff --git a/packages/core/src/defer/dom_triggers.ts b/packages/core/src/defer/dom_triggers.ts index 4600525a408..59831fb3059 100644 --- a/packages/core/src/defer/dom_triggers.ts +++ b/packages/core/src/defer/dom_triggers.ts @@ -23,7 +23,7 @@ import { import {assertElement, assertEqual} from '../util/assert'; import {NgZone} from '../zone'; import {storeTriggerCleanupFn} from './cleanup'; - +import {Observer, getViewportTriggers, onViewport} from '../../primitives/defer/src/triggers'; import { DEFER_BLOCK_STATE, DeferBlockInternalState, @@ -32,177 +32,45 @@ import { } from './interfaces'; import {getLDeferBlockDetails} from './utils'; -/** Configuration object used to register passive and capturing events. */ -const eventListenerOptions: AddEventListenerOptions = { - passive: true, - capture: true, -}; - -/** Keeps track of the currently-registered `on hover` triggers. */ -const hoverTriggers = new WeakMap(); - -/** Keeps track of the currently-registered `on interaction` triggers. */ -const interactionTriggers = new WeakMap(); - -/** Currently-registered `viewport` triggers. */ -const viewportTriggers = new WeakMap(); - -/** Names of the events considered as interaction events. */ -export const interactionEventNames = ['click', 'keydown'] as const; - -/** Names of the events considered as hover events. */ -export const hoverEventNames = ['mouseenter', 'mouseover', 'focusin'] as const; - -/** `IntersectionObserver` used to observe `viewport` triggers. */ -let intersectionObserver: IntersectionObserver | null = null; - -/** Number of elements currently observed with `viewport` triggers. */ -let observedViewportElements = 0; - -/** Object keeping track of registered callbacks for a deferred block trigger. */ -class DeferEventEntry { - callbacks = new Set(); - - 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): VoidFunction { - let entry = interactionTriggers.get(trigger); - - // If this is the first entry for this element, add the listeners. - if (!entry) { - // Note that 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); - } - } - }; -} - -/** - * Registers a hover trigger. - * @param trigger Element that is the trigger. - * @param callback Callback to be invoked when the trigger is hovered over. - */ -export function onHover(trigger: Element, callback: VoidFunction): VoidFunction { - let entry = hoverTriggers.get(trigger); - - // If this is the first entry for this element, add the listener. - if (!entry) { - entry = new DeferEventEntry(); - hoverTriggers.set(trigger, entry); - - for (const name of hoverEventNames) { - trigger.addEventListener(name, entry!.listener, eventListenerOptions); - } - } - - entry.callbacks.add(callback); - - return () => { - const {callbacks, listener} = entry!; - callbacks.delete(callback); - - if (callbacks.size === 0) { - for (const name of hoverEventNames) { - trigger.removeEventListener(name, listener, eventListenerOptions); - } - hoverTriggers.delete(trigger); - } - }; -} - -/** - * Registers a viewport trigger. + * Wrapper for onViewport trigger with angular specific Injector for resolving NgZone instance + * and creating an IntersectionObserver which can run outside of Angular zone. * @param trigger Element that is the trigger. * @param callback Callback to be invoked when the trigger comes into the viewport. * @param injector Injector that can be used by the trigger to resolve DI tokens. */ -export function onViewport( - trigger: Element, - callback: VoidFunction, - injector: Injector, -): VoidFunction { - const ngZone = injector.get(NgZone); - let entry = viewportTriggers.get(trigger); +export function onViewportWrapper(trigger: Element, callback: VoidFunction, injector: Injector) { + const angularInjectorObserver = new AngularIntersectionObserver(injector); + return onViewport(trigger, callback, angularInjectorObserver); +} - intersectionObserver = - intersectionObserver || - ngZone.runOutsideAngular(() => { +class AngularIntersectionObserver implements Observer{ + private intersectionObserver: IntersectionObserver; + private ngZone: NgZone; + constructor(injector: Injector) { + this.ngZone = injector.get(NgZone); + this.intersectionObserver = this.ngZone.runOutsideAngular(() => { return new IntersectionObserver((entries) => { for (const current of entries) { - // Only invoke the callbacks if the specific element is intersecting. - if (current.isIntersecting && viewportTriggers.has(current.target)) { - ngZone.run(viewportTriggers.get(current.target)!.listener); + if (current.isIntersecting && getViewportTriggers().has(current.target)) { + this.ngZone.run(getViewportTriggers().get(current.target)!.listener) } } - }); - }); - - if (!entry) { - entry = new DeferEventEntry(); - ngZone.runOutsideAngular(() => intersectionObserver!.observe(trigger)); - viewportTriggers.set(trigger, entry); - observedViewportElements++; + }) + }) } - entry.callbacks.add(callback); + observe(target: Element) { + this.ngZone.runOutsideAngular(() => this.intersectionObserver.observe(target)); + } - return () => { - // It's possible that a different cleanup callback fully removed this element already. - if (!viewportTriggers.has(trigger)) { - return; - } + unobserve(target: Element) { + this.intersectionObserver.unobserve(target); + } - entry!.callbacks.delete(callback); - - if (entry!.callbacks.size === 0) { - intersectionObserver?.unobserve(trigger); - viewportTriggers.delete(trigger); - observedViewportElements--; - } - - if (observedViewportElements === 0) { - intersectionObserver?.disconnect(); - intersectionObserver = null; - } - }; + disconnect() { + this.intersectionObserver.disconnect(); + } } /** diff --git a/packages/core/src/defer/instructions.ts b/packages/core/src/defer/instructions.ts index 0aed37854ec..095a164d50c 100644 --- a/packages/core/src/defer/instructions.ts +++ b/packages/core/src/defer/instructions.ts @@ -27,7 +27,9 @@ import { import {removeLViewOnDestroy, storeLViewOnDestroy} from '../render3/util/view_utils'; import {performanceMarkFeature} from '../util/performance'; import {invokeAllTriggerCleanupFns, storeTriggerCleanupFn} from './cleanup'; -import {onHover, onInteraction, onViewport, registerDomTrigger} from './dom_triggers'; +//import {onHover, onInteraction, onViewport, registerDomTrigger} from './dom_triggers'; +import {onViewportWrapper, registerDomTrigger} from './dom_triggers'; +import {onHover, onInteraction} from '../../primitives/defer/src/triggers'; import {onIdle} from './idle_scheduler'; import { DEFER_BLOCK_STATE, @@ -770,7 +772,7 @@ export function ɵɵdeferOnViewport(triggerIndex: number, walkUpTimes?: number) tNode, triggerIndex, walkUpTimes, - onViewport, + onViewportWrapper, () => triggerDeferBlock(TriggerType.Regular, lView, tNode), TriggerType.Regular, ); @@ -806,7 +808,7 @@ export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?: tNode, triggerIndex, walkUpTimes, - onViewport, + onViewportWrapper, () => triggerPrefetching(tDetails, lView, tNode), TriggerType.Prefetch, );