diff --git a/package.json b/package.json index 55d77f2..37c3f59 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "tsc && yarn run build && yarn test:playwright && testem ci && node test/headless/run", "serve": "yarn run build && node test/headless/server/app", - "test:headless": "mocha --require @babel/register test/headless/specs/**/*.js --exit", + "test:headless": "mocha --require @babel/register test/headless/specs/**/*.js test/headless/specs/*.js --exit --timeout 5000", "test:playwright": "playwright test --config=test/playwright/playwright.config.ts", "watch": "broccoli-timepiece exports", "build": "./scripts/build.sh", diff --git a/src/interfaces.ts b/src/interfaces.ts index c17e83e..75189bc 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -28,36 +28,69 @@ export interface SpanielObserverInit { USE_NATIVE_IO?: boolean; } +export interface TimeCompat { + highResTime: number; + unixTime: number; +} + export interface SpanielRecord { target: SpanielTrackedElement; payload: any; thresholdStates: SpanielThresholdState[]; - lastSeenEntry: IntersectionObserverEntry | null; + lastSeenEntry: InternalIntersectionObserverEntry | null; } export interface SpanielThresholdState { lastSatisfied: Boolean; - lastEntry: IntersectionObserverEntry | null; + lastEntry: InternalIntersectionObserverEntry | null; threshold: SpanielThreshold; - lastVisible: number; + lastVisible: TimeCompat; visible: boolean; timeoutId?: number; } export interface SpanielIntersectionObserverEntryInit { - time: DOMHighResTimeStamp; + highResTime: DOMHighResTimeStamp; + unixTime: number; rootBounds: DOMRectPojo; boundingClientRect: DOMRectPojo; intersectionRect: DOMRectPojo; target: SpanielTrackedElement; } -export interface SpanielObserverEntry extends IntersectionObserverEntryInit { +export interface SpanielRect extends DOMRectPojo { + readonly height: number; + readonly width: number; + readonly x: number; + readonly y: number; +} + +export interface SpanielObserverEntry { + isIntersecting: boolean; duration: number; + visibleTime: number; intersectionRatio: number; entering: boolean; label?: string; payload?: any; + unixTime: number; + highResTime: number; + time: number; + target: Element; + boundingClientRect: SpanielRect; + intersectionRect: SpanielRect; + rootBounds: SpanielRect | null; +} + +export interface InternalIntersectionObserverEntry { + time: number; + highResTime: DOMHighResTimeStamp; + target: Element; + boundingClientRect: SpanielRect; + intersectionRect: SpanielRect; + rootBounds: SpanielRect | null; + intersectionRatio: number; + isIntersecting: boolean; } export interface IntersectionObserverClass { diff --git a/src/intersection-observer.ts b/src/intersection-observer.ts index e044bb7..eaf2076 100644 --- a/src/intersection-observer.ts +++ b/src/intersection-observer.ts @@ -16,14 +16,15 @@ import { Frame, ElementScheduler, generateToken } from './metal/index'; import { SpanielTrackedElement, DOMString, - DOMRectPojo, IntersectionObserverInit, DOMMargin, - SpanielIntersectionObserverEntryInit + SpanielIntersectionObserverEntryInit, + InternalIntersectionObserverEntry, + DOMRectPojo } from './interfaces'; interface EntryEvent { - entry: IntersectionObserverEntry; + entry: InternalIntersectionObserverEntry; numSatisfiedThresholds: number; } @@ -37,7 +38,7 @@ function marginToRect(margin: DOMMargin): DOMRectPojo { bottom, right, width: right - left, - height: bottom - top + height: bottom - top, }; } @@ -142,16 +143,17 @@ export class SpanielIntersectionObserver implements IntersectionObserver { } } -function addRatio(entryInit: SpanielIntersectionObserverEntryInit): IntersectionObserverEntry { - const { time, rootBounds, boundingClientRect, intersectionRect, target } = entryInit; +function addRatio(entryInit: SpanielIntersectionObserverEntryInit): InternalIntersectionObserverEntry { + const { unixTime, highResTime, rootBounds, boundingClientRect, intersectionRect, target } = entryInit; const boundingArea = boundingClientRect.height * boundingClientRect.width; const intersectionRatio = boundingArea > 0 ? (intersectionRect.width * intersectionRect.height) / boundingArea : 0; return { - time, - rootBounds: pojoToToDOMRectReadOnly(rootBounds), - boundingClientRect: pojoToToDOMRectReadOnly(boundingClientRect), - intersectionRect: pojoToToDOMRectReadOnly(intersectionRect), + time: unixTime, + highResTime, + rootBounds, + boundingClientRect, + intersectionRect, target, intersectionRatio, isIntersecting: calculateIsIntersecting({ intersectionRect }) @@ -183,28 +185,31 @@ export function generateEntry( clientRect: ClientRect, el: HTMLElement, rootMargin: DOMMargin -): IntersectionObserverEntry { +): InternalIntersectionObserverEntry { if (el.style.display === 'none') { return { + time: frame.dateNow, + highResTime: frame.highResTime, boundingClientRect: emptyRect(), intersectionRatio: 0, intersectionRect: emptyRect(), isIntersecting: false, rootBounds: emptyRect(), - target: el, - time: frame.timestamp + target: el }; } let { bottom, right } = clientRect; + const left = frame.left + rootMargin.left; + const top = frame.top + rootMargin.top; let rootBounds: DOMRectPojo = { - left: frame.left + rootMargin.left, - top: frame.top + rootMargin.top, - x: frame.left + rootMargin.left, - y: frame.top + rootMargin.top, + left, + top, bottom: rootMargin.bottom, right: rootMargin.right, width: frame.width - (rootMargin.right + rootMargin.left), - height: frame.height - (rootMargin.bottom + rootMargin.top) + height: frame.height - (rootMargin.bottom + rootMargin.top), + y: top, + x: left }; let intersectX = Math.max(rootBounds.left, clientRect.left); @@ -213,11 +218,13 @@ export function generateEntry( let width = Math.min(rootBounds.left + rootBounds.width, clientRect.right) - intersectX; let height = Math.min(rootBounds.top + rootBounds.height, clientRect.bottom) - intersectY; + const interLeft = width >= 0 ? intersectX : 0; + const interTop = intersectY >= 0 ? intersectY : 0; let intersectionRect: DOMRectPojo = { - left: width >= 0 ? intersectX : 0, - top: intersectY >= 0 ? intersectY : 0, - x: width >= 0 ? intersectX : 0, - y: intersectY >= 0 ? intersectY : 0, + left: interLeft, + top: interTop, + x: interLeft, + y: interTop, width, height, right, @@ -225,8 +232,9 @@ export function generateEntry( }; return addRatio({ - time: frame.timestamp, - rootBounds: pojoToToDOMRectReadOnly(rootBounds), + unixTime: frame.dateNow, + highResTime: frame.highResTime, + rootBounds, target: el, boundingClientRect: pojoToToDOMRectReadOnly(marginToRect(clientRect)), intersectionRect: pojoToToDOMRectReadOnly(intersectionRect) diff --git a/src/metal/interfaces.ts b/src/metal/interfaces.ts index 0eda640..f8c462a 100644 --- a/src/metal/interfaces.ts +++ b/src/metal/interfaces.ts @@ -51,7 +51,8 @@ export interface ElementSchedulerInterface extends BaseSchedulerInterface { } export interface FrameInterface { - timestamp: number; + dateNow: number; + highResTime: DOMHighResTimeStamp; scrollTop: number; scrollLeft: number; width: number; diff --git a/src/metal/scheduler.ts b/src/metal/scheduler.ts index bac7601..f6ec9f8 100644 --- a/src/metal/scheduler.ts +++ b/src/metal/scheduler.ts @@ -33,7 +33,8 @@ let tokenCounter = 0; export class Frame implements FrameInterface { constructor( - public timestamp: number, + public dateNow: number, + public highResTime: number, public scrollTop: number, public scrollLeft: number, public width: number, @@ -47,6 +48,7 @@ export class Frame implements FrameInterface { const rootMeta = this.revalidateRootMeta(root); return new Frame( Date.now(), + performance.now(), rootMeta.scrollTop, rootMeta.scrollLeft, rootMeta.width, diff --git a/src/spaniel-observer.ts b/src/spaniel-observer.ts index e270b5f..5d11e39 100644 --- a/src/spaniel-observer.ts +++ b/src/spaniel-observer.ts @@ -11,7 +11,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import { DOMMargin, + DOMRectPojo, DOMString, + InternalIntersectionObserverEntry, IntersectionObserverInit, SpanielObserverEntry, SpanielObserverInit, @@ -26,7 +28,7 @@ import { generateToken, off, on, scheduleWork } from './metal/index'; import w from './metal/window-proxy'; import { calculateIsIntersecting } from './utils'; -let emptyRect = { x: 0, y: 0, width: 0, height: 0 }; +let emptyRect: DOMRectPojo = { x: 0, y: 0, width: 0, height: 0, bottom: 0, left: 0, top: 0, right: 0 }; export function DOMMarginToRootMargin(d: DOMMargin): DOMString { return `${d.top}px ${d.right}px ${d.bottom}px ${d.left}px`; @@ -108,7 +110,8 @@ export class SpanielObserver implements SpanielObserverInterface { this.paused = false; let ids = Object.keys(this.recordStore); - let time = this.generateObserverTimestamp(); + const highResTime = performance.now(); + const time = this.generateObserverTimestamp(); for (let i = 0; i < ids.length; i++) { let entry = this.recordStore[ids[i]].lastSeenEntry; if (entry) { @@ -117,6 +120,7 @@ export class SpanielObserver implements SpanielObserverInterface { intersectionRatio, boundingClientRect, time, + highResTime, isIntersecting, rootBounds, intersectionRect, @@ -134,20 +138,31 @@ export class SpanielObserver implements SpanielObserverInterface { this.queuedEntries = []; } } - private generateSpanielEntry(entry: IntersectionObserverEntry, state: SpanielThresholdState): SpanielObserverEntry { + private generateSpanielEntry( + entry: InternalIntersectionObserverEntry, + state: SpanielThresholdState + ): SpanielObserverEntry { let { intersectionRatio, rootBounds, boundingClientRect, intersectionRect, isIntersecting, time, target } = entry; let record = this.recordStore[(target).__spanielId]; - const timeOrigin = w.performance.timeOrigin || w.performance.timing.navigationStart; - const unixTime = this.usingNativeIo ? Math.floor(timeOrigin + time) : time; + const unixTime = this.usingNativeIo + ? Math.floor((w.performance.timeOrigin || w.performance.timing.navigationStart) + time) + : time; + const highResTime = this.usingNativeIo ? time : entry.highResTime; + if (!highResTime) { + throw new Error('Missing intersection entry timestamp'); + } return { intersectionRatio, isIntersecting, + unixTime, time: unixTime, + highResTime, rootBounds, boundingClientRect, intersectionRect, target: target, duration: 0, + visibleTime: isIntersecting ? unixTime : -1, entering: false, payload: record.payload, label: state.threshold.label @@ -155,20 +170,25 @@ export class SpanielObserver implements SpanielObserverInterface { } private handleRecordExiting(record: SpanielRecord) { const time = Date.now(); + const perfTime = performance.now(); record.thresholdStates.forEach((state: SpanielThresholdState) => { const boundingClientRect = record.lastSeenEntry && record.lastSeenEntry.boundingClientRect; this.handleThresholdExiting( { intersectionRatio: -1, isIntersecting: false, + unixTime: time, time, + highResTime: perfTime, payload: record.payload, label: state.threshold.label, entering: false, rootBounds: emptyRect, boundingClientRect: boundingClientRect || emptyRect, intersectionRect: emptyRect, - duration: time - state.lastVisible, + visibleTime: state.lastVisible.unixTime, + // Next line (duration) is always overwritten if the record becomes a callback event + duration: perfTime - state.lastVisible.highResTime, target: record.target }, state @@ -179,11 +199,12 @@ export class SpanielObserver implements SpanielObserverInterface { }); } private handleThresholdExiting(spanielEntry: SpanielObserverEntry, state: SpanielThresholdState) { - let { time } = spanielEntry; + let { highResTime } = spanielEntry; let hasTimeThreshold = !!state.threshold.time; if (state.lastSatisfied && (!hasTimeThreshold || (hasTimeThreshold && state.visible))) { // Make into function - spanielEntry.duration = time - state.lastVisible; + spanielEntry.duration = highResTime - state.lastVisible.highResTime; + spanielEntry.visibleTime = state.lastVisible.unixTime; spanielEntry.entering = false; state.visible = false; this.queuedEntries.push(spanielEntry); @@ -191,7 +212,7 @@ export class SpanielObserver implements SpanielObserverInterface { clearTimeout(state.timeoutId); } - private handleObserverEntry(entry: IntersectionObserverEntry) { + private handleObserverEntry(entry: InternalIntersectionObserverEntry) { let target = entry.target; let record = this.recordStore[target.__spanielId]; @@ -217,19 +238,26 @@ export class SpanielObserver implements SpanielObserverInterface { if (isSatisfied != state.lastSatisfied) { if (isSatisfied) { spanielEntry.entering = true; - state.lastVisible = spanielEntry.time; + state.lastVisible = { + highResTime: spanielEntry.highResTime, + unixTime: spanielEntry.unixTime + }; if (hasTimeThreshold) { const timerId: number = Number( setTimeout(() => { state.visible = true; - spanielEntry.duration = Date.now() - state.lastVisible; + spanielEntry.duration = performance.now() - state.lastVisible.highResTime; + spanielEntry.visibleTime = state.lastVisible.unixTime; this.callback([spanielEntry]); }, state.threshold.time) ); state.timeoutId = timerId; } else { state.visible = true; - spanielEntry.duration = Date.now() - state.lastVisible; + // TODO: Remove setting duration here, as it's irrelevant and should be very close to 0. + // It doesn't make sense to calculate duration when the entry represents entering, not + // exiting the viewport. + spanielEntry.duration = Date.now() - state.lastVisible.unixTime; this.queuedEntries.push(spanielEntry); } } else { @@ -286,7 +314,10 @@ export class SpanielObserver implements SpanielObserverInterface { lastEntry: null, threshold, visible: false, - lastVisible: 0 + lastVisible: { + unixTime: 0, + highResTime: -1 + } })) }; this.observer.observe(trackedTarget); diff --git a/src/watcher.ts b/src/watcher.ts index c0aff01..ff3c3fc 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -44,13 +44,12 @@ function onEntry(entries: SpanielObserverEntry[]) { const opts: WatcherCallbackOptions = { duration, boundingClientRect, - visibleTime: entry.time, + visibleTime: entry.visibleTime, intersectionRect }; if (entry.entering) { entry.payload.callback(label, opts); } else if (entry.label === 'impressed') { - opts.visibleTime = entry.time - entry.duration; entry.payload.callback('impression-complete', opts); } }); diff --git a/test/headless/specs/intersection-observer.js b/test/headless/specs/intersection-observer.js index 8bdeb87..4d06252 100644 --- a/test/headless/specs/intersection-observer.js +++ b/test/headless/specs/intersection-observer.js @@ -171,14 +171,15 @@ testModule( }); } + // TODO: It appears that the intersection observer polyfill does not respect the threshold parameter ['@test observing a non visible element and then scrolling just past threshold and then back out should fire twice']() { return this.context .evaluate(function() { - window.STATE.impressions = 0; + window.STATE.callbacks = 0; let target = document.querySelector('.tracked-item[data-id="5"]'); let observer = new spaniel.IntersectionObserver( function() { - window.STATE.impressions++; + window.STATE.callbacks++; }, { threshold: 0.75 @@ -186,14 +187,14 @@ testModule( ); observer.observe(target); }) - .wait(100) + .wait(200) .scrollTo(80) - .wait(100) - .scrollTo(70) - .wait(100) + .wait(200) + .scrollTo(0) + .wait(200) .getExecution() .evaluate(function() { - return window.STATE.impressions; + return window.STATE.callbacks; }) .then(function(result) { assert.equal(result, 2, 'Callback fired twice'); @@ -219,7 +220,7 @@ testModule( .wait(100) .scrollTo(105) .wait(100) - .scrollTo(95) + .scrollTo(0) .wait(100) .getExecution() .evaluate(function() { diff --git a/test/playwright/utils.ts b/test/playwright/utils.ts index 032c134..2fc96ff 100644 --- a/test/playwright/utils.ts +++ b/test/playwright/utils.ts @@ -75,13 +75,13 @@ export async function pageHide(page: Page): Promise { value: 'hidden', configurable: true })`); - // _visibilityHandler addon/services/tracking.js#747 await page.evaluate(`Object.defineProperty(document, 'hidden', { value: true, configurable: true })`); await page.evaluate(`document.dispatchEvent(new Event('visibilitychange'))`); } + export async function pageShow(page: Page): Promise { await page.evaluate(`Object.defineProperty(document, 'visibilityState', { value: 'visible',