Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add defer viewport and hover triggers #51874

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ function MyApp_Template(rf, ctx) {
$r3$.ɵɵdeferPrefetchOnIdle();
$r3$.ɵɵdeferPrefetchOnImmediate();
$r3$.ɵɵdeferPrefetchOnTimer(1337);
$r3$.ɵɵdeferPrefetchOnHover();
$r3$.ɵɵdeferPrefetchOnHover(0, -1);
$r3$.ɵɵdeferPrefetchOnInteraction(0, -1);
$r3$.ɵɵdeferPrefetchOnViewport("button");
$r3$.ɵɵdeferPrefetchOnViewport(0, -1);
}
if (rf & 2) {
$r3$.ɵɵtextInterpolate1(" ", ctx.message, " ");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ function MyApp_Template(rf, ctx) {
$r3$.ɵɵdeferOnIdle();
$r3$.ɵɵdeferOnImmediate();
$r3$.ɵɵdeferOnTimer(1337);
$r3$.ɵɵdeferOnHover();
$r3$.ɵɵdeferOnHover(0, -1);
$r3$.ɵɵdeferOnInteraction(0, -1);
$r3$.ɵɵdeferOnViewport("button");
$r3$.ɵɵdeferOnViewport(0, -1);
}
if (rf & 2) {
$r3$.ɵɵtextInterpolate1(" ", ctx.message, " ");
Expand Down
84 changes: 46 additions & 38 deletions packages/compiler/src/render3/view/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,53 +1403,61 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
[o.literal(timer.delay)]);
}

// `deferOnHover()`
// `deferOnHover(index, walkUpTimes)`
if (hover) {
this.creationInstruction(
hover.sourceSpan, prefetch ? R3.deferPrefetchOnHover : R3.deferOnHover);
this.domNodeBasedTrigger(
'hover', hover, metadata, prefetch ? R3.deferPrefetchOnHover : R3.deferOnHover);
}

// TODO: `deferOnInteraction(index, walkUpTimes)`
// `deferOnInteraction(index, walkUpTimes)`
if (interaction) {
const instructionRef = prefetch ? R3.deferPrefetchOnInteraction : R3.deferOnInteraction;
const triggerEl = metadata.triggerElements.get(interaction);

// Don't generate anything if a trigger cannot be resolved.
// We'll have template diagnostics to surface these to users.
if (triggerEl) {
this.creationInstruction(interaction.sourceSpan, instructionRef, () => {
const location = this.elementLocations.get(triggerEl);

if (!location) {
throw new Error(
`Could not determine location of reference passed into ` +
`'interaction' trigger. Template may not have been fully analyzed.`);
}
this.domNodeBasedTrigger(
'interaction', interaction, metadata,
prefetch ? R3.deferPrefetchOnInteraction : R3.deferOnInteraction);
}

// A negative depth means that the trigger is inside the placeholder.
// Cap it at -1 since we only care whether or not it's negative.
const depth = Math.max(this.level - location.level, -1);
const params = [o.literal(location.index)];
// `deferOnViewport(index, walkUpTimes)`
if (viewport) {
this.domNodeBasedTrigger(
'viewport', viewport, metadata,
prefetch ? R3.deferPrefetchOnViewport : R3.deferOnViewport);
}
}

// The most common case should be a trigger within the view so we can omit a depth of
// zero. For triggers in parent views and in the placeholder we need to pass it in.
if (depth !== 0) {
params.push(o.literal(depth));
}
private domNodeBasedTrigger(
name: string,
trigger: t.InteractionDeferredTrigger|t.HoverDeferredTrigger|t.ViewportDeferredTrigger,
metadata: R3DeferBlockMetadata, instructionRef: o.ExternalReference) {
const triggerEl = metadata.triggerElements.get(trigger);

return params;
});
}
// Don't generate anything if a trigger cannot be resolved.
// We'll have template diagnostics to surface these to users.
if (!triggerEl) {
return;
}

// TODO(crisbeto): currently the reference is passed as a string.
// Update this once we figure out how we should refer to the target.
// `deferOnViewport(target)`
if (viewport) {
this.creationInstruction(
viewport.sourceSpan, prefetch ? R3.deferPrefetchOnViewport : R3.deferOnViewport,
[o.literal(viewport.reference)]);
}
this.creationInstruction(trigger.sourceSpan, instructionRef, () => {
const location = this.elementLocations.get(triggerEl);

if (!location) {
throw new Error(
`Could not determine location of reference passed into ` +
`'${name}' trigger. Template may not have been fully analyzed.`);
}

// A negative depth means that the trigger is inside the placeholder.
// Cap it at -1 since we only care whether or not it's negative.
const depth = Math.max(this.level - location.level, -1);
const params = [o.literal(location.index)];

// The most common case should be a trigger within the view so we can omit a depth of
// zero. For triggers in parent views and in the placeholder we need to pass it in.
if (depth !== 0) {
params.push(o.literal(depth));
}

return params;
});
}

private allocateDataSlot() {
Expand Down
56 changes: 49 additions & 7 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {isPlatformBrowser} from '../util/misc_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 {onHover, onInteraction, onViewport} from './defer_events';
import {ɵɵtemplate} from './template';

/**
Expand Down Expand Up @@ -230,15 +230,37 @@ export function ɵɵdeferPrefetchOnTimer(delay: number) {} // TODO: implement r

/**
* Creates runtime data structures for the `on hover` deferred trigger.
* @param triggerIndex Index at which to find the trigger element.
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
* @codeGenApi
*/
export function ɵɵdeferOnHover() {} // TODO: implement runtime logic.
export function ɵɵdeferOnHover(triggerIndex: number, walkUpTimes?: number) {
const lView = getLView();
const tNode = getCurrentTNode()!;

renderPlaceholder(lView, tNode);
registerDomTrigger(
lView, tNode, triggerIndex, walkUpTimes, onHover, () => triggerDeferBlock(lView, tNode));
}

/**
* Creates runtime data structures for the `prefetch on hover` deferred trigger.
* @param triggerIndex Index at which to find the trigger element.
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
* @codeGenApi
*/
export function ɵɵdeferPrefetchOnHover() {} // TODO: implement runtime logic.
export function ɵɵdeferPrefetchOnHover(triggerIndex: number, walkUpTimes?: number) {
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, onHover,
() => triggerPrefetching(tDetails, lView));
}
}

/**
* Creates runtime data structures for the `on interaction` deferred trigger.
Expand Down Expand Up @@ -277,17 +299,37 @@ export function ɵɵdeferPrefetchOnInteraction(triggerIndex: number, walkUpTimes

/**
* Creates runtime data structures for the `on viewport` deferred trigger.
* @param target Optional element on which to listen for hover events.
* @param triggerIndex Index at which to find the trigger element.
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
* @codeGenApi
*/
export function ɵɵdeferOnViewport(target?: unknown) {} // TODO: implement runtime logic.
export function ɵɵdeferOnViewport(triggerIndex: number, walkUpTimes?: number) {
const lView = getLView();
const tNode = getCurrentTNode()!;

renderPlaceholder(lView, tNode);
registerDomTrigger(
lView, tNode, triggerIndex, walkUpTimes, onViewport, () => triggerDeferBlock(lView, tNode));
}

/**
* Creates runtime data structures for the `prefetch on viewport` deferred trigger.
* @param target Optional element on which to listen for hover events.
* @param triggerIndex Index at which to find the trigger element.
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
* @codeGenApi
*/
export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: implement runtime logic.
export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?: number) {
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, onViewport,
() => triggerPrefetching(tDetails, lView));
}
}

/********** Helper functions **********/

Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/render3/instructions/defer_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
* found in the LICENSE file at https://angular.io/license
*/

import {inject, Injector, ɵɵdefineInjectable} from '../../di';
import {NgZone} from '../../zone';

/** 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<Element, DeferEventEntry>();

/** Keeps track of the currently-registered `on interaction` triggers. */
const interactionTriggers = new WeakMap<Element, DeferEventEntry>();

Expand Down Expand Up @@ -73,3 +79,105 @@ export function onInteraction(trigger: Element, callback: VoidFunction) {
}
};
}

/**
* 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();
trigger.addEventListener('mouseenter', entry.listener, eventListenerOptions);
hoverTriggers.set(trigger, entry);
}

entry.callbacks.add(callback);

return () => {
const {callbacks, listener} = entry!;
callbacks.delete(callback);

if (callbacks.size === 0) {
trigger.removeEventListener('mouseenter', listener, eventListenerOptions);
hoverTriggers.delete(trigger);
}
};
}

/**
* 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 {
return injector.get(DeferIntersectionManager).register(trigger, callback);
}

/** Keeps track of the registered `viewport` triggers. */
class DeferIntersectionManager {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to keep the injectable for the viewport triggers, because the state ended up leaking between tests.

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: DeferIntersectionManager,
providedIn: 'root',
factory: () => new DeferIntersectionManager(inject(NgZone)),
});

/** `IntersectionObserver` used to observe `viewport` triggers. */
private intersectionObserver: IntersectionObserver|null = null;

/** Number of elements currently observed with `viewport` triggers. */
private observedViewportElements = 0;

/** Currently-registered `viewport` triggers. */
private viewportTriggers = new WeakMap<Element, DeferEventEntry>();

constructor(private ngZone: NgZone) {}

register(trigger: Element, callback: VoidFunction): VoidFunction {
let entry = this.viewportTriggers.get(trigger);

if (!this.intersectionObserver) {
this.intersectionObserver =
this.ngZone.runOutsideAngular(() => new IntersectionObserver(this.intersectionCallback));
}

if (!entry) {
entry = new DeferEventEntry();
this.ngZone.runOutsideAngular(() => this.intersectionObserver!.observe(trigger));
this.viewportTriggers.set(trigger, entry);
this.observedViewportElements++;
}

entry.callbacks.add(callback);

return () => {
entry!.callbacks.delete(callback);

if (entry!.callbacks.size === 0) {
this.intersectionObserver?.unobserve(trigger);
this.viewportTriggers.delete(trigger);
this.observedViewportElements--;
}

if (this.observedViewportElements === 0) {
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
}
};
}

private intersectionCallback: IntersectionObserverCallback = entries => {
for (const current of entries) {
// Only invoke the callbacks if the specific element is intersecting.
if (current.isIntersecting && this.viewportTriggers.has(current.target)) {
this.ngZone.run(this.viewportTriggers.get(current.target)!.listener);
}
}
}
}