direction observer performance improvements, cache new feature and typing improvements

This commit is contained in:
Rene
2021-02-06 16:17:55 +01:00
parent 1d63bd4ef4
commit 7f192b4f60
6 changed files with 207 additions and 67 deletions
@@ -16,26 +16,24 @@ import {
isFunction, isFunction,
} from 'support'; } from 'support';
type TruthyOrFalsy = boolean | '' | 0 | null | undefined;
type StringNullUndefined = string | null | undefined; type StringNullUndefined = string | null | undefined;
export type DOMObserverEventContentChange = export type DOMObserverEventContentChange =
| Array<[StringNullUndefined, ((elms: Node[]) => string) | StringNullUndefined] | null | undefined> | Array<[StringNullUndefined, ((elms: Node[]) => string) | StringNullUndefined] | null | undefined>
| false | false
| ''
| null | null
| undefined; | undefined;
export type DOMObserverIgnoreContentChange = ( export type DOMObserverIgnoreContentChange = (
mutation: MutationRecord, mutation: MutationRecord,
isNestedTarget: TruthyOrFalsy, isNestedTarget: boolean,
domObserverTarget: HTMLElement, domObserverTarget: HTMLElement,
domObserverOptions: DOMObserverOptions | undefined domObserverOptions: DOMObserverOptions | undefined
) => TruthyOrFalsy; ) => boolean;
export type DOMObserverIgnoreTargetAttrChange = ( export type DOMObserverIgnoreTargetAttrChange = (
target: Node, target: Node,
attributeName: string, attributeName: string,
oldAttributeValue: string | null, oldAttributeValue: string | null,
newAttributeValue: string | null newAttributeValue: string | null
) => TruthyOrFalsy; ) => boolean;
export interface DOMObserverOptions { export interface DOMObserverOptions {
_observeContent?: boolean; // do observe children and trigger content change _observeContent?: boolean; // do observe children and trigger content change
_attributes?: string[]; // observed attributes _attributes?: string[]; // observed attributes
@@ -190,7 +188,7 @@ export const createDOMObserver = (
const baseAssertion = isNestedTarget const baseAssertion = isNestedTarget
? !ignoreTargetChange(mutationTarget, attributeName!, oldValue, attributeValue as string | null) ? !ignoreTargetChange(mutationTarget, attributeName!, oldValue, attributeValue as string | null)
: notOnlyAttrChanged || contentAttrChanged; : notOnlyAttrChanged || contentAttrChanged;
const contentFinalChanged = baseAssertion && !ignoreContentChange(mutation, isNestedTarget, target, options); const contentFinalChanged = baseAssertion && !ignoreContentChange(mutation, !!isNestedTarget, target, options);
push(totalAddedNodes, addedNodes); push(totalAddedNodes, addedNodes);
@@ -14,12 +14,12 @@ import {
preventDefault, preventDefault,
stopPropagation, stopPropagation,
addClass, addClass,
isString,
equalWH, equalWH,
push, push,
cAF, cAF,
rAF, rAF,
ResizeObserverConstructor, ResizeObserverConstructor,
isArray,
} from 'support'; } from 'support';
import { CSSDirection } from 'typings'; import { CSSDirection } from 'typings';
import { getEnvironment } from 'environment'; import { getEnvironment } from 'environment';
@@ -36,28 +36,60 @@ const animationStartEventName = 'animationstart';
const scrollEventName = 'scroll'; const scrollEventName = 'scroll';
const scrollAmount = 3333333; const scrollAmount = 3333333;
const getDirection = (elm: HTMLElement): CSSDirection => style(elm, 'direction') as CSSDirection; const getDirection = (elm: HTMLElement): CSSDirection => style(elm, 'direction') as CSSDirection;
const domRectHasDimensions = (rect?: DOMRectReadOnly) => rect && (rect.height > 0 || rect.width > 0);
interface SizeObserverEntry {
contentRect: DOMRectReadOnly;
}
export type SizeObserverOptions = { _direction?: boolean; _appear?: boolean }; export type SizeObserverOptions = { _direction?: boolean; _appear?: boolean };
export const createSizeObserver = ( export const createSizeObserver = (
target: HTMLElement, target: HTMLElement,
onSizeChangedCallback: (directionCache?: Cache<CSSDirection>) => any, onSizeChangedCallback: (directionCache?: Cache<CSSDirection>) => any,
options?: SizeObserverOptions options?: SizeObserverOptions
): (() => void) => { ): (() => void) => {
const { _direction: direction = false, _appear: appear = false } = options || {}; const { _direction: observeDirectionChange = false, _appear: observeAppearChange = false } = options || {};
const rtlScrollBehavior = getEnvironment()._rtlScrollBehavior; const rtlScrollBehavior = getEnvironment()._rtlScrollBehavior;
const baseElements = createDOM(`<div class="${classNameSizeObserver}"><div class="${classNameSizeObserverListener}"></div></div>`); const baseElements = createDOM(`<div class="${classNameSizeObserver}"><div class="${classNameSizeObserverListener}"></div></div>`);
const sizeObserver = baseElements[0] as HTMLElement; const sizeObserver = baseElements[0] as HTMLElement;
const listenerElement = sizeObserver.firstChild as HTMLElement; const listenerElement = sizeObserver.firstChild as HTMLElement;
const onSizeChangedCallbackProxy = (directionCache?: Cache<CSSDirection>) => { const updateResizeObserverContentRectCache = createCache<DOMRectReadOnly, DOMRectReadOnly>(0, {
if (direction) { _alwaysUpdateValues: true,
const rtl = getDirection(sizeObserver) === 'rtl'; _equal: (currVal, newVal) =>
!(
!currVal || // if no initial value
// if from display: none to display: block
(!domRectHasDimensions(currVal) && domRectHasDimensions(newVal))
),
});
const onSizeChangedCallbackProxy = (sizeChangedContext?: Cache<CSSDirection> | SizeObserverEntry[] | Event) => {
const directionCacheValue = sizeChangedContext && (sizeChangedContext as Cache<CSSDirection>)._value;
let skip: boolean = false;
let doDirectionScroll = true; // always true if sizeChangedContext is Event
// if triggered from RO.
if (isArray(sizeChangedContext) && sizeChangedContext.length > 0) {
const { _previous, _value, _changed } = updateResizeObserverContentRectCache(0, sizeChangedContext.pop()!.contentRect);
skip = !_previous || !domRectHasDimensions(_value); // skip on initial RO. call or if display is none
doDirectionScroll = !skip && _changed; // direction scroll when not skipping and changing from display: none to block, false otherwise
}
// else if its triggered with DirectionCache
else if (directionCacheValue) {
doDirectionScroll = (sizeChangedContext as Cache<CSSDirection>)._changed; // direction scroll when DirectionCache changed, false toherwise
}
if (observeDirectionChange && doDirectionScroll) {
const rtl = (directionCacheValue || getDirection(sizeObserver)) === 'rtl';
scrollLeft(sizeObserver, rtl ? (rtlScrollBehavior.n ? -scrollAmount : rtlScrollBehavior.i ? 0 : scrollAmount) : scrollAmount); scrollLeft(sizeObserver, rtl ? (rtlScrollBehavior.n ? -scrollAmount : rtlScrollBehavior.i ? 0 : scrollAmount) : scrollAmount);
scrollTop(sizeObserver, scrollAmount); scrollTop(sizeObserver, scrollAmount);
} }
onSizeChangedCallback(isString((directionCache || {})._value) ? directionCache : undefined);
if (!skip) {
onSizeChangedCallback(directionCacheValue ? (sizeChangedContext as Cache<CSSDirection>) : undefined);
}
}; };
const offListeners: (() => void)[] = []; const offListeners: (() => void)[] = [];
let appearCallback: ((...args: any) => any) | null = appear ? onSizeChangedCallbackProxy : null; let appearCallback: ((...args: any) => any) | false = observeAppearChange ? onSizeChangedCallbackProxy : false;
if (ResizeObserverConstructor) { if (ResizeObserverConstructor) {
const resizeObserverInstance = new ResizeObserverConstructor(onSizeChangedCallbackProxy); const resizeObserverInstance = new ResizeObserverConstructor(onSizeChangedCallbackProxy);
@@ -120,10 +152,10 @@ export const createSizeObserver = (
height: scrollAmount, height: scrollAmount,
}); });
reset(); reset();
appearCallback = appear ? () => onScroll() : reset; appearCallback = observeAppearChange ? () => onScroll() : reset;
} }
if (direction) { if (observeDirectionChange) {
const updateDirectionCache = createCache(() => getDirection(sizeObserver)); const updateDirectionCache = createCache(() => getDirection(sizeObserver));
push( push(
offListeners, offListeners,
@@ -152,7 +184,7 @@ export const createSizeObserver = (
push( push(
offListeners, offListeners,
on(sizeObserver, animationStartEventName, appearCallback, { on(sizeObserver, animationStartEventName, appearCallback, {
// Fire only once for "CSS is ready" event // Fire only once for "CSS is ready" event if ResizeObserver strategy is used
_once: !!ResizeObserverConstructor, _once: !!ResizeObserverConstructor,
}) })
); );
@@ -20,7 +20,7 @@ export const createTrinsicObserver = (
const trinsicObserver = createDOM(`<div class="${classNameTrinsicObserver}"></div>`)[0] as HTMLElement; const trinsicObserver = createDOM(`<div class="${classNameTrinsicObserver}"></div>`)[0] as HTMLElement;
const offListeners: (() => void)[] = []; const offListeners: (() => void)[] = [];
const updateHeightIntrinsicCache = createCache<boolean, IntersectionObserverEntry | WH<number>>( const updateHeightIntrinsicCache = createCache<boolean, IntersectionObserverEntry | WH<number>>(
(ioEntryOrSize) => (ioEntryOrSize: IntersectionObserverEntry | WH<number>) =>
(ioEntryOrSize! as WH<number>).h === 0 || (ioEntryOrSize! as WH<number>).h === 0 ||
(ioEntryOrSize! as IntersectionObserverEntry).isIntersecting || (ioEntryOrSize! as IntersectionObserverEntry).isIntersecting ||
(ioEntryOrSize! as IntersectionObserverEntry).intersectionRatio > 0, (ioEntryOrSize! as IntersectionObserverEntry).intersectionRatio > 0,
+21 -7
View File
@@ -5,26 +5,38 @@ export interface Cache<T> {
} }
export interface CacheOptions<T> { export interface CacheOptions<T> {
// Custom comparison function if shallow compare isn't enough. Returns true if nothing changed.
_equal?: EqualCachePropFunction<T>; _equal?: EqualCachePropFunction<T>;
// Initial value for _value
_initialValue?: T; _initialValue?: T;
// If true updates always _value and _previous, otherwise they update only when changed
_alwaysUpdateValues?: boolean;
} }
export type CacheUpdate<T, C> = (force?: boolean | 0, context?: C) => Cache<T>; export type CacheUpdate<T, C> = undefined extends C ? (force?: boolean | 0, context?: C) => Cache<T> : (force: boolean | 0, context: C) => Cache<T>;
export type UpdateCachePropFunction<T, C> = (context?: C, current?: T, previous?: T) => T; export type UpdateCachePropFunction<T, C> = undefined extends C
? (context?: C, current?: T, previous?: T) => T
: C extends T
? ((context: C, current?: T, previous?: T) => T) | 0
: (context: C, current?: T, previous?: T) => T;
export type EqualCachePropFunction<T> = (currentVal?: T, newVal?: T) => boolean; export type EqualCachePropFunction<T> = (currentVal?: T, newVal?: T) => boolean;
export const createCache = <T, C = undefined>(update: UpdateCachePropFunction<T, C>, options?: CacheOptions<T>): CacheUpdate<T, C> => { export const createCache = <T, C = undefined>(update: UpdateCachePropFunction<T, C>, options?: CacheOptions<T>): CacheUpdate<T, C> => {
const { _equal, _initialValue } = options || {}; const { _equal, _initialValue, _alwaysUpdateValues } = options || {};
let _value: T | undefined = _initialValue; let _value: T | undefined = _initialValue;
let _previous: T | undefined; let _previous: T | undefined;
return (force, context) => {
const cacheUpdate = ((force?: boolean | 0, context?: C) => {
const curr = _value; const curr = _value;
const newVal = update(context, _value, _previous); // @ts-ignore
// update can only not be a function if C extends T as described in "UpdateCachePropFunction" type definition
// if C extends T the cast (context as T) is perfectly valid
const newVal = update ? update(context, _value, _previous) : (context as T);
const changed = force || (_equal ? !_equal(curr, newVal) : curr !== newVal); const changed = force || (_equal ? !_equal(curr, newVal) : curr !== newVal);
if (changed) { if (changed || _alwaysUpdateValues) {
_value = newVal; _value = newVal;
_previous = curr; _previous = curr;
} }
@@ -34,5 +46,7 @@ export const createCache = <T, C = undefined>(update: UpdateCachePropFunction<T,
_previous, _previous,
_changed: changed, _changed: changed,
}; };
}; }) as CacheUpdate<T, C>;
return cacheUpdate;
}; };
@@ -3,7 +3,8 @@ import './index.scss';
import should from 'should'; import should from 'should';
import { generateClassChangeSelectCallback, iterateSelect } from '@/testing-browser/Select'; import { generateClassChangeSelectCallback, iterateSelect } from '@/testing-browser/Select';
import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult'; import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult';
import { hasDimensions, offsetSize, WH, style, ResizeObserverConstructor } from 'support'; import { timeout } from '@/testing-browser/timeout';
import { hasDimensions, offsetSize, WH, style } from 'support';
import { createSizeObserver } from 'observers/sizeObserver'; import { createSizeObserver } from 'observers/sizeObserver';
@@ -65,8 +66,7 @@ const iterate = async (select: HTMLSelectElement | null, afterEach?: () => any)
const offsetSizeChanged = currOffsetSize.w !== newOffsetSize.w || currOffsetSize.h !== newOffsetSize.h; const offsetSizeChanged = currOffsetSize.w !== newOffsetSize.w || currOffsetSize.h !== newOffsetSize.h;
const contentSizeChanged = currContentSize.w !== newContentSize.w || currContentSize.h !== newContentSize.h; const contentSizeChanged = currContentSize.w !== newContentSize.w || currContentSize.h !== newContentSize.h;
const dirChanged = currDir !== newDir; const dirChanged = currDir !== newDir;
// ResizeObserver Polyfill doesn't react on display: none, so make an exception there const dimensions = hasDimensions(targetElm as HTMLElement);
const dimensions = ResizeObserverConstructor ? true : hasDimensions(targetElm as HTMLElement);
const observerElm = targetElm?.firstElementChild as HTMLElement; const observerElm = targetElm?.firstElementChild as HTMLElement;
// no overflow if not needed // no overflow if not needed
@@ -87,6 +87,9 @@ const iterate = async (select: HTMLSelectElement | null, afterEach?: () => any)
} }
}); });
} }
if (!dimensions) {
await timeout(100);
}
}, },
afterEach, afterEach,
}); });
@@ -30,50 +30,88 @@ describe('cache', () => {
expect(_changed).toBe(true); expect(_changed).toBe(true);
}); });
test('creates and updates cache with context', () => { describe('context', () => {
interface ContextObj { test('creates and updates cache with context', () => {
test: string; interface ContextObj {
even: number; test: string;
} even: number;
const updateFn = jest.fn(); }
const updater = (context?: ContextObj, current?: boolean, previous?: boolean) => { const updateFn = jest.fn();
updateFn(context, current, previous); const updater = (context?: ContextObj, current?: boolean, previous?: boolean) => {
return context!.test === 'test' || context!.even % 2 === 0; updateFn(context, current, previous);
}; return context!.test === 'test' || context!.even % 2 === 0;
const update = createCache(updater); };
const firstCtx = { test: 'test', even: 2 }; const update = createCache(updater);
const firstCtx = { test: 'test', even: 2 };
let { _value, _previous, _changed } = update(0, firstCtx); let { _value, _previous, _changed } = update(0, firstCtx);
expect(updateFn).toHaveBeenLastCalledWith(firstCtx, undefined, undefined); expect(updateFn).toHaveBeenLastCalledWith(firstCtx, undefined, undefined);
expect(_value).toBe(true); expect(_value).toBe(true);
expect(_previous).toBe(undefined); expect(_previous).toBe(undefined);
expect(_changed).toBe(true); expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, firstCtx)); ({ _value, _previous, _changed } = update(0, firstCtx));
expect(updateFn).toHaveBeenLastCalledWith(firstCtx, true, undefined); expect(updateFn).toHaveBeenLastCalledWith(firstCtx, true, undefined);
expect(_value).toBe(true); expect(_value).toBe(true);
expect(_previous).toBe(undefined); expect(_previous).toBe(undefined);
expect(_changed).toBe(false); expect(_changed).toBe(false);
const scndCtx = { test: 'nah', even: 1 }; const scndCtx = { test: 'nah', even: 1 };
({ _value, _previous, _changed } = update(0, scndCtx)); ({ _value, _previous, _changed } = update(0, scndCtx));
expect(updateFn).toHaveBeenLastCalledWith(scndCtx, true, undefined); expect(updateFn).toHaveBeenLastCalledWith(scndCtx, true, undefined);
expect(_value).toBe(false); expect(_value).toBe(false);
expect(_previous).toBe(true); expect(_previous).toBe(true);
expect(_changed).toBe(true); expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, scndCtx)); ({ _value, _previous, _changed } = update(0, scndCtx));
expect(updateFn).toHaveBeenLastCalledWith(scndCtx, false, true); expect(updateFn).toHaveBeenLastCalledWith(scndCtx, false, true);
expect(_value).toBe(false); expect(_value).toBe(false);
expect(_previous).toBe(true); expect(_previous).toBe(true);
expect(_changed).toBe(false); expect(_changed).toBe(false);
({ _value, _previous, _changed } = update(true, scndCtx)); ({ _value, _previous, _changed } = update(true, scndCtx));
expect(updateFn).toHaveBeenLastCalledWith(scndCtx, false, true); expect(updateFn).toHaveBeenLastCalledWith(scndCtx, false, true);
expect(_value).toBe(false); expect(_value).toBe(false);
expect(_previous).toBe(false); expect(_previous).toBe(false);
expect(_changed).toBe(true); expect(_changed).toBe(true);
});
test('creates and updates cache with context shorthand', () => {
interface ContextObj {
test: string;
even: number;
}
const update = createCache<ContextObj, ContextObj>(0);
const firstCtx = { test: 'test', even: 2 };
let { _value, _previous, _changed } = update(0, firstCtx);
expect(_value).toBe(firstCtx);
expect(_previous).toBe(undefined);
expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, firstCtx));
expect(_value).toBe(firstCtx);
expect(_previous).toBe(undefined);
expect(_changed).toBe(false);
const scndCtx = { test: 'nah', even: 1 };
({ _value, _previous, _changed } = update(0, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(firstCtx);
expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(firstCtx);
expect(_changed).toBe(false);
({ _value, _previous, _changed } = update(true, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(scndCtx);
expect(_changed).toBe(true);
});
}); });
describe('equal', () => { describe('equal', () => {
@@ -131,7 +169,7 @@ describe('cache', () => {
}); });
describe('inital value', () => { describe('inital value', () => {
test('creates and updates cache with inital value', () => { test('creates and updates cache with initialValue', () => {
const [fn, updater] = createUpdater((i) => i); const [fn, updater] = createUpdater((i) => i);
const update = createCache<number>(updater, { _initialValue: 0 }); const update = createCache<number>(updater, { _initialValue: 0 });
@@ -148,7 +186,7 @@ describe('cache', () => {
expect(_changed).toBe(true); expect(_changed).toBe(true);
}); });
test('creates and updates cache with inital value and equal', () => { test('creates and updates cache with initialValue and equal', () => {
const obj = { a: -1, b: -1 }; const obj = { a: -1, b: -1 };
const [fn, updater] = createUpdater((i) => ({ a: i, b: i + 1 })); const [fn, updater] = createUpdater((i) => ({ a: i, b: i + 1 }));
const update = createCache<typeof obj>(updater, { _initialValue: obj, _equal: (a, b) => a?.a === b?.a && a?.b === b?.b }); const update = createCache<typeof obj>(updater, { _initialValue: obj, _equal: (a, b) => a?.a === b?.a && a?.b === b?.b });
@@ -167,6 +205,61 @@ describe('cache', () => {
}); });
}); });
describe('always update values', () => {
test('creates and updates cache with alwaysUpdateValues and equal always true', () => {
const [fn, updater] = createUpdater((i) => i);
const update = createCache<number>(updater, { _alwaysUpdateValues: true, _equal: () => true });
let { _value, _previous, _changed } = update();
expect(fn).toHaveBeenLastCalledWith(undefined, undefined, undefined);
expect(_value).toBe(1);
expect(_previous).toBe(undefined);
expect(_changed).toBe(false);
({ _value, _previous, _changed } = update());
expect(fn).toHaveBeenLastCalledWith(undefined, 1, undefined);
expect(_value).toBe(2);
expect(_previous).toBe(1);
expect(_changed).toBe(false);
});
test('creates and updates cache with context shorthand and alwaysUpdateValues', () => {
interface ContextObj {
test: string;
even: number;
}
const update = createCache<ContextObj, ContextObj>(0, { _alwaysUpdateValues: true });
const firstCtx = { test: 'test', even: 2 };
let { _value, _previous, _changed } = update(0, firstCtx);
expect(_value).toBe(firstCtx);
expect(_previous).toBe(undefined);
expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, firstCtx));
expect(_value).toBe(firstCtx);
expect(_previous).toBe(firstCtx);
expect(_changed).toBe(false);
const scndCtx = { test: 'nah', even: 1 };
({ _value, _previous, _changed } = update(0, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(firstCtx);
expect(_changed).toBe(true);
({ _value, _previous, _changed } = update(0, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(scndCtx);
expect(_changed).toBe(false);
({ _value, _previous, _changed } = update(true, scndCtx));
expect(_value).toBe(scndCtx);
expect(_previous).toBe(scndCtx);
expect(_changed).toBe(true);
});
});
describe('constant', () => { describe('constant', () => {
test('updates constant initially without intial value', () => { test('updates constant initially without intial value', () => {
const [fn, updater] = createUpdater(() => true); const [fn, updater] = createUpdater(() => true);