Skip to content

Commit ad23764

Browse files
crisbetoAndrewKushnir
authored andcommitted
feat(core): support IntersectionObserver options in viewport triggers (#64130)
Adds support for customizing the `IntersectionObserver` options for the `on viewport`, `prefetch on viewport` and `hydrate on viewport` triggers. Note that the options need to be a static object literal, e.g. `@defer (on viewport(trigger, {rootMargin: '123px'})`. Fixes #52799. PR Close #64130
1 parent ddeef60 commit ad23764

File tree

10 files changed

+290
-52
lines changed

10 files changed

+290
-52
lines changed

packages/core/primitives/defer/src/triggers.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const hoverTriggers = new WeakMap<Element, DeferEventEntry>();
1919
const interactionTriggers = new WeakMap<Element, DeferEventEntry>();
2020

2121
/** Currently-registered `viewport` triggers. */
22-
export const viewportTriggers = new WeakMap<Element, DeferEventEntry>();
22+
export const viewportTriggers = new WeakMap<Element, Map<string, DeferEventEntry>>();
2323

2424
/** Names of the events considered as interaction events. */
2525
export const interactionEventNames = ['click', 'keydown'] as const;
@@ -28,10 +28,7 @@ export const interactionEventNames = ['click', 'keydown'] as const;
2828
export const hoverEventNames = ['mouseenter', 'mouseover', 'focusin'] as const;
2929

3030
/** `IntersectionObserver` used to observe `viewport` triggers. */
31-
let intersectionObserver: IntersectionObserver | null = null;
32-
33-
/** Number of elements currently observed with `viewport` triggers. */
34-
let observedViewportElements = 0;
31+
const intersectionObservers = new Map<string, {observer: IntersectionObserver; count: number}>();
3532

3633
/** Object keeping track of registered callbacks for a deferred block trigger. */
3734
class DeferEventEntry {
@@ -130,14 +127,15 @@ export function onHover(trigger: Element, callback: VoidFunction): VoidFunction
130127
* Used to create an IntersectionObserver instance.
131128
* @return IntersectionObserver that is used by onViewport
132129
*/
133-
export function createIntersectionObserver() {
130+
export function createIntersectionObserver(options?: IntersectionObserverInit) {
131+
const key = getIntersectionObserverKey(options);
134132
return new IntersectionObserver((entries) => {
135133
for (const current of entries) {
136134
if (current.isIntersecting && viewportTriggers.has(current.target)) {
137-
viewportTriggers.get(current.target)!.listener();
135+
viewportTriggers.get(current.target)?.get(key)?.listener();
138136
}
139137
}
140-
});
138+
}, options);
141139
}
142140

143141
/**
@@ -152,37 +150,71 @@ export function createIntersectionObserver() {
152150
export function onViewport(
153151
trigger: Element,
154152
callback: VoidFunction,
155-
observerFactoryFn: () => IntersectionObserver,
153+
observerFactoryFn: (options?: IntersectionObserverInit) => IntersectionObserver,
154+
options?: IntersectionObserverInit,
156155
): VoidFunction {
157-
let entry = viewportTriggers.get(trigger);
156+
const key = getIntersectionObserverKey(options);
157+
let entry = viewportTriggers.get(trigger)?.get(key);
158+
159+
if (!intersectionObservers.has(key)) {
160+
intersectionObservers.set(key, {observer: observerFactoryFn(options), count: 0});
161+
}
158162

159-
intersectionObserver = intersectionObserver || observerFactoryFn();
163+
const config = intersectionObservers.get(key)!;
160164

161165
if (!entry) {
162166
entry = new DeferEventEntry();
163-
intersectionObserver!.observe(trigger);
164-
viewportTriggers.set(trigger, entry);
165-
observedViewportElements++;
167+
config.observer.observe(trigger);
168+
169+
let triggerConfig = viewportTriggers.get(trigger);
170+
171+
if (triggerConfig) {
172+
triggerConfig.set(key, entry);
173+
} else {
174+
triggerConfig = new Map();
175+
viewportTriggers.set(trigger, triggerConfig);
176+
}
177+
178+
triggerConfig.set(key, entry);
179+
config.count++;
166180
}
167181

168182
entry.callbacks.add(callback);
169183

170184
return () => {
171-
if (!viewportTriggers.has(trigger)) {
185+
if (!viewportTriggers.get(trigger)?.has(key)) {
172186
return;
173187
}
174188

175189
entry!.callbacks.delete(callback);
176190

177191
if (entry!.callbacks.size === 0) {
178-
intersectionObserver?.unobserve(trigger);
179-
viewportTriggers.delete(trigger);
180-
observedViewportElements--;
192+
config.observer.unobserve(trigger);
193+
config.count--;
194+
195+
const triggerConfig = viewportTriggers.get(trigger);
196+
197+
if (triggerConfig) {
198+
triggerConfig.delete(key);
199+
200+
if (triggerConfig.size === 0) {
201+
viewportTriggers.delete(trigger);
202+
}
203+
}
181204
}
182205

183-
if (observedViewportElements === 0) {
184-
intersectionObserver?.disconnect();
185-
intersectionObserver = null;
206+
if (config.count === 0) {
207+
config.observer.disconnect();
208+
intersectionObservers.delete(key);
186209
}
187210
};
188211
}
212+
213+
/** Generates a string that can be used to find identical intersection observer option objects. */
214+
function getIntersectionObserverKey(options: IntersectionObserverInit | undefined): string {
215+
if (!options) {
216+
return '';
217+
}
218+
219+
return `${options.rootMargin}/${typeof options.threshold === 'number' ? options.threshold : options.threshold?.join('\n')}`;
220+
}

packages/core/src/defer/dom_triggers.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,18 @@ import {getLDeferBlockDetails} from './utils';
3939
* @param callback Callback to be invoked when the trigger comes into the viewport.
4040
* @param injector Injector that can be used by the trigger to resolve DI tokens.
4141
*/
42-
export function onViewportWrapper(trigger: Element, callback: VoidFunction, injector: Injector) {
42+
export function onViewportWrapper(
43+
trigger: Element,
44+
callback: VoidFunction,
45+
injector: Injector,
46+
wrapperOptions?: IntersectionObserverInit,
47+
) {
4348
const ngZone = injector.get(NgZone);
4449
return onViewport(
4550
trigger,
4651
() => ngZone.run(callback),
47-
() => ngZone.runOutsideAngular(() => createIntersectionObserver()),
52+
(options) => ngZone.runOutsideAngular(() => createIntersectionObserver(options)),
53+
wrapperOptions,
4854
);
4955
}
5056

@@ -59,7 +65,7 @@ export function onViewportWrapper(trigger: Element, callback: VoidFunction, inje
5965
export function getTriggerLView(
6066
deferredHostLView: LView,
6167
deferredTNode: TNode,
62-
walkUpTimes: number | undefined,
68+
walkUpTimes: number | undefined | null,
6369
): LView | null {
6470
// The trigger is in the same view, we don't need to traverse.
6571
if (walkUpTimes == null) {
@@ -113,14 +119,20 @@ export function getTriggerElement(triggerLView: LView, triggerIndex: number): El
113119
* the deferred block.
114120
* @param type Trigger type to distinguish between regular and prefetch triggers.
115121
*/
116-
export function registerDomTrigger(
122+
export function registerDomTrigger<O>(
117123
initialLView: LView,
118124
tNode: TNode,
119125
triggerIndex: number,
120-
walkUpTimes: number | undefined,
121-
registerFn: (element: Element, callback: VoidFunction, injector: Injector) => VoidFunction,
126+
walkUpTimes: number | undefined | null,
127+
registerFn: (
128+
element: Element,
129+
callback: VoidFunction,
130+
injector: Injector,
131+
options?: O,
132+
) => VoidFunction,
122133
callback: VoidFunction,
123134
type: TriggerType,
135+
options?: O,
124136
) {
125137
const injector = initialLView[INJECTOR];
126138
const zone = injector.get(NgZone);
@@ -173,6 +185,7 @@ export function registerDomTrigger(
173185
});
174186
},
175187
injector,
188+
options,
176189
);
177190

178191
// The trigger and deferred block might be in different LViews.

packages/core/src/defer/instructions.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ export function ɵɵdeferHydrateOnTimer(delay: number) {
541541
if (!shouldAttachTrigger(TriggerType.Hydrate, lView, tNode)) return;
542542

543543
const hydrateTriggers = getHydrateTriggers(getTView(), tNode);
544-
hydrateTriggers.set(DeferBlockTrigger.Timer, {delay});
544+
hydrateTriggers.set(DeferBlockTrigger.Timer, {type: DeferBlockTrigger.Timer, delay});
545545

546546
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
547547
// We are on the server and SSR for defer blocks is enabled.
@@ -751,15 +751,26 @@ export function ɵɵdeferHydrateOnInteraction() {
751751
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
752752
* @codeGenApi
753753
*/
754-
export function ɵɵdeferOnViewport(triggerIndex: number, walkUpTimes?: number) {
754+
export function ɵɵdeferOnViewport(
755+
triggerIndex: number,
756+
walkUpTimes?: number | null,
757+
options?: IntersectionObserverInit,
758+
) {
755759
const lView = getLView();
756760
const tNode = getCurrentTNode()!;
757761

758762
if (ngDevMode) {
763+
const args: string[] = [];
764+
if (walkUpTimes !== undefined && walkUpTimes !== -1) {
765+
args.push('<target>');
766+
}
767+
if (options) {
768+
args.push(JSON.stringify(options));
769+
}
759770
trackTriggerForDebugging(
760771
lView[TVIEW],
761772
tNode,
762-
`on viewport${walkUpTimes === -1 ? '' : '(<target>)'}`,
773+
`on viewport${args.length === 0 ? '' : `(${args.join(', ')})`}`,
763774
);
764775
}
765776

@@ -777,6 +788,7 @@ export function ɵɵdeferOnViewport(triggerIndex: number, walkUpTimes?: number)
777788
onViewportWrapper,
778789
() => triggerDeferBlock(TriggerType.Regular, lView, tNode),
779790
TriggerType.Regular,
791+
options,
780792
);
781793
}
782794
}
@@ -787,15 +799,26 @@ export function ɵɵdeferOnViewport(triggerIndex: number, walkUpTimes?: number)
787799
* @param walkUpTimes Number of times to walk up/down the tree hierarchy to find the trigger.
788800
* @codeGenApi
789801
*/
790-
export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?: number) {
802+
export function ɵɵdeferPrefetchOnViewport(
803+
triggerIndex: number,
804+
walkUpTimes?: number | null,
805+
options?: IntersectionObserverInit,
806+
) {
791807
const lView = getLView();
792808
const tNode = getCurrentTNode()!;
793809

794810
if (ngDevMode) {
811+
const args: string[] = [];
812+
if (walkUpTimes !== undefined && walkUpTimes !== -1) {
813+
args.push('<target>');
814+
}
815+
if (options) {
816+
args.push(JSON.stringify(options));
817+
}
795818
trackTriggerForDebugging(
796819
lView[TVIEW],
797820
tNode,
798-
`prefetch on viewport${walkUpTimes === -1 ? '' : '(<target>)'}`,
821+
`prefetch on viewport${args.length === 0 ? '' : `(${args.join(', ')})`}`,
799822
);
800823
}
801824

@@ -813,6 +836,7 @@ export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?:
813836
onViewportWrapper,
814837
() => triggerPrefetching(tDetails, lView, tNode),
815838
TriggerType.Prefetch,
839+
options,
816840
);
817841
}
818842
}
@@ -821,18 +845,30 @@ export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?:
821845
* Creates runtime data structures for the `on viewport` hydrate trigger.
822846
* @codeGenApi
823847
*/
824-
export function ɵɵdeferHydrateOnViewport() {
848+
export function ɵɵdeferHydrateOnViewport(options?: IntersectionObserverInit) {
825849
const lView = getLView();
826850
const tNode = getCurrentTNode()!;
827851

828852
if (ngDevMode) {
829-
trackTriggerForDebugging(lView[TVIEW], tNode, 'hydrate on viewport');
853+
trackTriggerForDebugging(
854+
lView[TVIEW],
855+
tNode,
856+
`hydrate on viewport${options ? `(${JSON.stringify(options)})` : ''}`,
857+
);
830858
}
831859

832860
if (!shouldAttachTrigger(TriggerType.Hydrate, lView, tNode)) return;
833861

834862
const hydrateTriggers = getHydrateTriggers(getTView(), tNode);
835-
hydrateTriggers.set(DeferBlockTrigger.Viewport, null);
863+
hydrateTriggers.set(
864+
DeferBlockTrigger.Viewport,
865+
options
866+
? {
867+
type: DeferBlockTrigger.Viewport,
868+
intersectionObserverOptions: options,
869+
}
870+
: null,
871+
);
836872

837873
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
838874
// We are on the server and SSR for defer blocks is enabled.

packages/core/src/defer/interfaces.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,20 @@ export const enum DeferBlockTrigger {
203203
Never,
204204
}
205205

206-
/** * Describes specified delay (in ms) in the `hydrate on timer()` trigger. */
206+
/** Describes specified delay (in ms) in the `hydrate on timer()` trigger. */
207207
export interface HydrateTimerTriggerDetails {
208-
delay: number;
208+
type: DeferBlockTrigger.Timer;
209+
delay?: number;
210+
}
211+
212+
/** Describes the config for a `hydrate on viewport` trigger. */
213+
export interface HydrateViewportTriggerDetails {
214+
type: DeferBlockTrigger.Viewport;
215+
intersectionObserverOptions?: IntersectionObserverInit;
209216
}
210217

211218
/** * Describes all possible hydration trigger details specified in a template. */
212-
export type HydrateTriggerDetails = HydrateTimerTriggerDetails;
219+
export type HydrateTriggerDetails = HydrateTimerTriggerDetails | HydrateViewportTriggerDetails;
213220

214221
/**
215222
* Describes the initial state of this defer block instance.

packages/core/src/defer/triggering.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,9 @@ export function processAndInitTriggers(
682682
timerElements.push(elementTrigger);
683683
}
684684
if (blockSummary.hydrate.viewport) {
685+
if (typeof blockSummary.hydrate.viewport !== 'boolean') {
686+
elementTrigger.intersectionObserverOptions = blockSummary.hydrate.viewport;
687+
}
685688
viewportElements.push(elementTrigger);
686689
}
687690
}
@@ -711,6 +714,7 @@ function setViewportTriggers(injector: Injector, elementTriggers: ElementTrigger
711714
elementTrigger.el,
712715
() => triggerHydrationFromBlockName(injector, elementTrigger.blockName),
713716
injector,
717+
elementTrigger.intersectionObserverOptions,
714718
);
715719
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
716720
}

packages/core/src/hydration/annotate.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,10 @@ function serializeHydrateTriggers(
492492
if (serializableDeferBlockTrigger.has(trigger)) {
493493
if (details === null) {
494494
triggers.push(trigger);
495-
} else {
495+
} else if (details.type === DeferBlockTrigger.Timer) {
496496
triggers.push({trigger, delay: details.delay});
497+
} else {
498+
triggers.push({trigger, intersectionObserverOptions: details.intersectionObserverOptions});
497499
}
498500
}
499501
}

packages/core/src/hydration/interfaces.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export interface SerializedDeferBlock {
187187
export interface SerializedTriggerDetails {
188188
trigger: DeferBlockTrigger;
189189
delay?: number;
190+
intersectionObserverOptions?: IntersectionObserverInit;
190191
}
191192

192193
/**
@@ -285,7 +286,12 @@ export interface DehydratedIcuData {
285286
*/
286287
export interface BlockSummary {
287288
data: SerializedDeferBlock;
288-
hydrate: {idle: boolean; immediate: boolean; viewport: boolean; timer: number | null};
289+
hydrate: {
290+
idle: boolean;
291+
immediate: boolean;
292+
viewport: true | IntersectionObserverInit | null;
293+
timer: number | null;
294+
};
289295
}
290296

291297
/**
@@ -295,4 +301,5 @@ export interface ElementTrigger {
295301
el: HTMLElement;
296302
blockName: string;
297303
delay?: number;
304+
intersectionObserverOptions?: IntersectionObserverInit;
298305
}

0 commit comments

Comments
 (0)