Skip to content

Commit

Permalink
Use only DOMHighResTimeStamp to calculate duration
Browse files Browse the repository at this point in the history
Previously, we were combining Date.now() and DOMHighResTimeStamp values to calculate duration. These two value types use different clocks, so we should avoid combining the two value types where possible, since using two different clocks leaves us vulnerable to asymetrical issues. The native intersection observer uses DOMHighResTimeStamp, so we should use that type wherever possible in calculations.

This change also removes the native-* files, which are superseded by USE_NATIVE_IO and never part of the public API.

Thanks to @xg-wang for pointing out some potential issues affecting one clock and not the other, which could in turn cause asymmetrical bugs:
w3c/hr-time#115
mdn/content#4713
  • Loading branch information
asakusuma committed Aug 18, 2021
1 parent c8a9111 commit 23a148b
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 55 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 38 additions & 5 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 32 additions & 24 deletions src/intersection-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -37,7 +38,7 @@ function marginToRect(margin: DOMMargin): DOMRectPojo {
bottom,
right,
width: right - left,
height: bottom - top
height: bottom - top,
};
}

Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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);
Expand All @@ -213,20 +218,23 @@ 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,
bottom
};

return addRatio({
time: frame.timestamp,
rootBounds: pojoToToDOMRectReadOnly(rootBounds),
unixTime: frame.dateNow,
highResTime: frame.highResTime,
rootBounds,
target: <SpanielTrackedElement>el,
boundingClientRect: pojoToToDOMRectReadOnly(marginToRect(clientRect)),
intersectionRect: pojoToToDOMRectReadOnly(intersectionRect)
Expand Down
3 changes: 2 additions & 1 deletion src/metal/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export interface ElementSchedulerInterface extends BaseSchedulerInterface {
}

export interface FrameInterface {
timestamp: number;
dateNow: number;
highResTime: DOMHighResTimeStamp;
scrollTop: number;
scrollLeft: number;
width: number;
Expand Down
4 changes: 3 additions & 1 deletion src/metal/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
57 changes: 44 additions & 13 deletions src/spaniel-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

import {
DOMMargin,
DOMRectPojo,
DOMString,
InternalIntersectionObserverEntry,
IntersectionObserverInit,
SpanielObserverEntry,
SpanielObserverInit,
Expand All @@ -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`;
Expand Down Expand Up @@ -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) {
Expand All @@ -117,6 +120,7 @@ export class SpanielObserver implements SpanielObserverInterface {
intersectionRatio,
boundingClientRect,
time,
highResTime,
isIntersecting,
rootBounds,
intersectionRect,
Expand All @@ -134,41 +138,57 @@ 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[(<SpanielTrackedElement>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: <SpanielTrackedElement>target,
duration: 0,
visibleTime: isIntersecting ? unixTime : -1,
entering: false,
payload: record.payload,
label: state.threshold.label
};
}
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
Expand All @@ -179,19 +199,20 @@ 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);
}

clearTimeout(state.timeoutId);
}
private handleObserverEntry(entry: IntersectionObserverEntry) {
private handleObserverEntry(entry: InternalIntersectionObserverEntry) {
let target = <SpanielTrackedElement>entry.target;
let record = this.recordStore[target.__spanielId];

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 23a148b

Please sign in to comment.