Skip to content

Commit fba08c7

Browse files
asakusumalynchbomb
authored andcommitted
Handle zero-area and display: none cases
Attempts to satsify the spec: https://www.w3.org/TR/intersection-observer/#update-intersection-observations-algo isIntersecting, non-zero area, and display:nonen are all related, so fixing in one swoop. Fixes: #93 #73 Related issues: w3c/IntersectionObserver#69 w3c/IntersectionObserver#222
1 parent 11f6824 commit fba08c7

9 files changed

+118
-39
lines changed

src/index.ts

+5-7
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,23 @@ Copyright 2017 LinkedIn Corp. Licensed under the Apache License,
33
Version 2.0 (the "License"); you may not use this file except in
44
compliance with the License. You may obtain a copy of the License
55
at http://www.apache.org/licenses/LICENSE-2.0
6-
6+
77
Unless required by applicable law or agreed to in writing, software
88
distributed under the License is distributed on an "AS IS" BASIS,
99
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
*/
1111

1212
import { SpanielIntersectionObserver, generateEntry } from './intersection-observer';
1313

14-
import { entrySatisfiesRatio } from './utils';
15-
16-
import { SpanielTrackedElement, SpanielObserverEntry, DOMString, DOMMargin } from './interfaces';
14+
import { SpanielTrackedElement, DOMMargin } from './interfaces';
1715

1816
export { Watcher, WatcherConfig } from './watcher';
1917

2018
import { SpanielObserver } from './spaniel-observer';
2119

2220
import { setGlobalEngine, getGlobalEngine } from './metal/engine';
2321

24-
import { Scheduler, getGlobalScheduler, on, off, scheduleWork, scheduleRead, Frame } from './metal/index';
22+
import { getGlobalScheduler, on, off, scheduleWork, scheduleRead, Frame } from './metal/index';
2523

2624
import w from './metal/window-proxy';
2725

@@ -46,13 +44,13 @@ export function queryElement(el: Element, callback: (clientRect: ClientRect, fra
4644
}
4745

4846
export function elementSatisfiesRatio(
49-
el: Element,
47+
el: HTMLElement,
5048
ratio: number = 0,
5149
callback: (result: Boolean) => void,
5250
rootMargin: DOMMargin = { top: 0, bottom: 0, left: 0, right: 0 }
5351
) {
5452
queryElement(el, (clientRect: ClientRect, frame: Frame) => {
5553
let entry = generateEntry(frame, clientRect, el, rootMargin);
56-
callback(entrySatisfiesRatio(entry, ratio));
54+
callback(entry.isIntersecting && entry.intersectionRatio >= ratio);
5755
});
5856
}

src/interfaces.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
99
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
*/
1111

12-
export interface SpanielTrackedElement extends Element {
12+
export interface SpanielTrackedElement extends HTMLElement {
1313
__spanielId: string;
1414
}
1515

src/intersection-observer.ts

+37-9
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
99
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
*/
1111

12-
import { entrySatisfiesRatio } from './utils';
12+
import { calculateIsIntersecting } from './utils';
1313

1414
import { Frame, QueueDOMElementInterface, DOMQueue, ElementScheduler, Engine, generateToken } from './metal/index';
1515

@@ -65,7 +65,7 @@ export class SpanielIntersectionObserver implements IntersectionObserver {
6565
public thresholds: number[];
6666
private records: { [index: string]: EntryEvent };
6767

68-
observe(target: Element) {
68+
observe(target: HTMLElement) {
6969
let trackedTarget = target as SpanielTrackedElement;
7070

7171
let id = (trackedTarget.__spanielId = trackedTarget.__spanielId || generateToken());
@@ -79,7 +79,7 @@ export class SpanielIntersectionObserver implements IntersectionObserver {
7979
);
8080
return id;
8181
}
82-
private onTick(frame: Frame, id: string, clientRect: DOMRectReadOnly, el: Element) {
82+
private onTick(frame: Frame, id: string, clientRect: DOMRectReadOnly, el: SpanielTrackedElement) {
8383
let { numSatisfiedThresholds, entry } = this.generateEntryEvent(frame, clientRect, el);
8484
let record: EntryEvent =
8585
this.records[id] ||
@@ -88,8 +88,12 @@ export class SpanielIntersectionObserver implements IntersectionObserver {
8888
numSatisfiedThresholds: 0
8989
});
9090

91-
if (numSatisfiedThresholds !== record.numSatisfiedThresholds) {
91+
if (
92+
numSatisfiedThresholds !== record.numSatisfiedThresholds ||
93+
entry.isIntersecting !== record.entry.isIntersecting
94+
) {
9295
record.numSatisfiedThresholds = numSatisfiedThresholds;
96+
record.entry = entry;
9397
this.scheduler.scheduleWork(() => {
9498
this.callback([entry]);
9599
});
@@ -106,14 +110,14 @@ export class SpanielIntersectionObserver implements IntersectionObserver {
106110
takeRecords(): IntersectionObserverEntry[] {
107111
return [];
108112
}
109-
private generateEntryEvent(frame: Frame, clientRect: DOMRectReadOnly, el: Element): EntryEvent {
113+
private generateEntryEvent(frame: Frame, clientRect: DOMRectReadOnly, el: HTMLElement): EntryEvent {
110114
let count: number = 0;
111115
let entry = generateEntry(frame, clientRect, el, this.rootMarginObj);
112116
let ratio = entry.intersectionRatio;
113117

114118
for (let i = 0; i < this.thresholds.length; i++) {
115119
let threshold = this.thresholds[i];
116-
if (entrySatisfiesRatio(entry, threshold)) {
120+
if (entry.intersectionRatio >= threshold) {
117121
count++;
118122
}
119123
}
@@ -152,7 +156,7 @@ function addRatio(entryInit: SpanielIntersectionObserverEntryInit): Intersection
152156
intersectionRect,
153157
target,
154158
intersectionRatio,
155-
isIntersecting: intersectionRatio > 0
159+
isIntersecting: calculateIsIntersecting({ intersectionRect })
156160
};
157161
}
158162

@@ -182,13 +186,37 @@ export class IntersectionObserverEntry implements IntersectionObserverEntryInit
182186
};
183187
*/
184188

189+
function emptyRect(): ClientRect | DOMRect {
190+
return {
191+
bottom: 0,
192+
height: 0,
193+
left: 0,
194+
right: 0,
195+
top: 0,
196+
width: 0,
197+
x: 0,
198+
y: 0
199+
};
200+
}
201+
185202
export function generateEntry(
186203
frame: Frame,
187204
clientRect: DOMRectReadOnly,
188-
el: Element,
205+
el: HTMLElement,
189206
rootMargin: DOMMargin
190207
): IntersectionObserverEntry {
191-
let { top, bottom, left, right } = clientRect;
208+
if (el.style.display === 'none') {
209+
return {
210+
boundingClientRect: emptyRect(),
211+
intersectionRatio: 0,
212+
intersectionRect: emptyRect(),
213+
isIntersecting: false,
214+
rootBounds: emptyRect(),
215+
target: el,
216+
time: frame.timestamp
217+
};
218+
}
219+
let { bottom, right } = clientRect;
192220
let rootBounds: ClientRect = {
193221
left: frame.left + rootMargin.left,
194222
top: frame.top + rootMargin.top,

src/native-spaniel-observer.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
99
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
*/
1111

12-
import { entrySatisfiesRatio } from './utils';
12+
import { calculateIsIntersecting } from './utils';
1313

1414
import {
1515
IntersectionObserverInit,
@@ -187,9 +187,10 @@ export class SpanielObserver implements SpanielObserverInterface {
187187
let hasTimeThreshold = !!state.threshold.time;
188188
let spanielEntry: SpanielObserverEntry = this.generateSpanielEntry(entry, state);
189189

190-
const ratioSatisfied = entrySatisfiesRatio(entry, state.threshold.ratio);
190+
const ratioSatisfied = entry.intersectionRatio >= state.threshold.ratio;
191+
const isIntersecting = calculateIsIntersecting(entry);
191192

192-
if (ratioSatisfied && !state.lastSatisfied) {
193+
if (ratioSatisfied && !state.lastSatisfied && isIntersecting) {
193194
spanielEntry.entering = true;
194195
if (hasTimeThreshold) {
195196
state.lastVisible = time;

src/spaniel-observer.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
99
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
*/
1111

12-
import { entrySatisfiesRatio } from './utils';
12+
import { calculateIsIntersecting } from './utils';
1313

1414
import { SpanielIntersectionObserver } from './intersection-observer';
1515

@@ -191,9 +191,10 @@ export class SpanielObserver implements SpanielObserverInterface {
191191
let hasTimeThreshold = !!state.threshold.time;
192192
let spanielEntry: SpanielObserverEntry = this.generateSpanielEntry(entry, state);
193193

194-
const ratioSatisfied = entrySatisfiesRatio(entry, state.threshold.ratio);
194+
const ratioSatisfied = entry.intersectionRatio >= state.threshold.ratio;
195+
const isIntersecting = calculateIsIntersecting(entry);
195196

196-
if (ratioSatisfied && !state.lastSatisfied) {
197+
if (ratioSatisfied && !state.lastSatisfied && isIntersecting) {
197198
spanielEntry.entering = true;
198199
if (hasTimeThreshold) {
199200
state.lastVisible = time;

src/utils.ts

+2-15
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
import { SpanielClientRectInterface } from './metal/interfaces';
22

3-
export function entrySatisfiesRatio(entry: IntersectionObserverEntry, threshold: number) {
4-
let { boundingClientRect, intersectionRatio } = entry;
5-
6-
// Edge case where item has no actual area
7-
if (boundingClientRect.width === 0 || boundingClientRect.height === 0) {
8-
let { boundingClientRect, intersectionRect } = entry;
9-
return (
10-
boundingClientRect.left === intersectionRect.left &&
11-
boundingClientRect.top === intersectionRect.top &&
12-
intersectionRect.width >= 0 &&
13-
intersectionRect.height >= 0
14-
);
15-
} else {
16-
return intersectionRatio > threshold || (intersectionRatio === 1 && threshold === 1);
17-
}
3+
export function calculateIsIntersecting({ intersectionRect }: { intersectionRect: ClientRect }) {
4+
return intersectionRect.width > 0 || intersectionRect.height > 0;
185
}
196

207
export function getBoundingClientRect(element: Element): SpanielClientRectInterface {

test/headless/context.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
Copyright 2017 LinkedIn Corp. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
 You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
3-
3+
44
Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS, 
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
55
*/
66

test/headless/run.js

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ server.stdout.on('data', data => {
1616
'--require',
1717
'@babel/register',
1818
'test/headless/specs/**/*.js',
19+
'test/headless/specs/*.js',
1920
'--exit',
2021
'--timeout',
2122
'5000'

test/headless/specs/intersection-observer.js

+63
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Unless required by applicable law or agreed to in writing, software
distribute
77
import { assert } from 'chai';
88
import { default as testModule, TestClass } from './../test-module';
99

10+
import constants from './../../constants.js';
11+
12+
const {
13+
time: { IMPRESSION_THRESHOLD }
14+
} = constants;
15+
1016
testModule(
1117
'IntersectionObserver',
1218
class extends TestClass {
@@ -55,6 +61,63 @@ testModule(
5561
});
5662
}
5763

64+
['@test observing a hidden element should fire an event with a ratio of 0']() {
65+
return this.context
66+
.evaluate(() => {
67+
window.STATE.intersectionEvents = 0;
68+
window.STATE.impressions = 0;
69+
let target = (window.testTarget = document.querySelector('.tracked-item[data-id="1"]'));
70+
target.style.display = 'none';
71+
let observer = new spaniel.IntersectionObserver(function(entries) {
72+
window.STATE.intersectionEvents++;
73+
74+
if (entries[0].intersectionRatio > 0) {
75+
window.STATE.impressions++;
76+
}
77+
});
78+
observer.observe(target);
79+
})
80+
.wait(IMPRESSION_THRESHOLD)
81+
.getExecution()
82+
.evaluate(function() {
83+
return window.STATE;
84+
})
85+
.then(function({ impressions, intersectionEvents }) {
86+
assert.equal(impressions, 0, 'No visible events');
87+
assert.equal(intersectionEvents, 1, 'Callback fired once');
88+
});
89+
}
90+
91+
['@test hiding an observed element should fire an event without isIntersecting']() {
92+
return this.context
93+
.evaluate(() => {
94+
window.STATE.intersectionEvents = 0;
95+
window.STATE.impressions = 0;
96+
let target = (window.testTarget = document.querySelector('.tracked-item[data-id="1"]'));
97+
let observer = new spaniel.IntersectionObserver(function(entries) {
98+
window.STATE.intersectionEvents++;
99+
100+
if (entries[0].isIntersecting) {
101+
window.STATE.impressions++;
102+
}
103+
});
104+
observer.observe(target);
105+
})
106+
.wait(IMPRESSION_THRESHOLD)
107+
.evaluate(function() {
108+
window.testTarget.style.display = 'none';
109+
})
110+
.wait(IMPRESSION_THRESHOLD)
111+
.getExecution()
112+
.evaluate(function() {
113+
return window.STATE;
114+
})
115+
.then(function({ impressions, intersectionEvents }) {
116+
assert.equal(intersectionEvents, 2, 'Callback fired twice');
117+
assert.equal(impressions, 1, 'One visible event');
118+
});
119+
}
120+
58121
['@test observing a non visible element should not fire']() {
59122
return this.context
60123
.evaluate(function() {

0 commit comments

Comments
 (0)