Skip to content

Commit 16b5571

Browse files
committed
readability
1 parent 368eaca commit 16b5571

File tree

10 files changed

+236
-118
lines changed

10 files changed

+236
-118
lines changed

packages/driver/cypress/e2e/memory/virtual-scroll-stress.cy.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ describe('Virtual Scrolling Stress Tests', {
3737
it('should load basic virtual list without crashing', () => {
3838
cy.get('button').contains('Load Basic List').click()
3939

40-
cy.get('#basicList .item')
41-
.should('have.length.greaterThan', 0)
40+
cy.get('#basicList .item', { log: false })
41+
.should('have.length.greaterThan', 0, { log: false })
4242
})
4343

4444
it('should handle normal scrolling without crashing', () => {
Lines changed: 12 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,13 @@
11
import $elements from '../elements'
2-
3-
import { unwrap, wrap, isJquery } from '../jquery'
2+
import { unwrap, isJquery } from '../jquery'
43
const { isOption, isOptgroup, isBody, isHTML } = $elements
4+
import { VisibilityTests, VisibilityResultKind } from './visibility-tests/VisibilityTest'
55

6-
const DEBUG = false
7-
8-
function debug (...args: any[]) {
9-
if (DEBUG) {
10-
// eslint-disable-next-line no-console
11-
console.log('DEBUG:', ...args)
12-
}
6+
interface VisibilityOptions {
7+
checkOpacity: boolean
138
}
149

15-
function memoize<T> (fn: (...args: any[]) => T): (...args: any[]) => T {
16-
const cache = new Map<any, { result: T, timestamp: number }>()
17-
18-
return (...args: any[]) => {
19-
const key = args
20-
21-
const cached = cache.get(key)
22-
23-
if (cached && cached.timestamp > Date.now() - 100) {
24-
return cached.result
25-
}
26-
27-
const result = fn(...args)
28-
29-
cache.set(key, { result, timestamp: Date.now() })
30-
31-
return result
32-
}
33-
}
34-
35-
const getBoundingClientRect = memoize((el: HTMLElement) => el.getBoundingClientRect())
36-
37-
function subDivideRect ({ x, y, width, height }: DOMRect): DOMRect[] {
38-
return [
39-
DOMRect.fromRect({
40-
x,
41-
y,
42-
width: width / 2,
43-
height: height / 2,
44-
}),
45-
DOMRect.fromRect({
46-
x: x + width / 2,
47-
y,
48-
width: width / 2,
49-
height: height / 2,
50-
}),
51-
DOMRect.fromRect({
52-
x,
53-
y: y + height / 2,
54-
width: width / 2,
55-
height: height / 2,
56-
}),
57-
DOMRect.fromRect({
58-
x: x + width / 2,
59-
y: y + height / 2,
60-
width: width / 2,
61-
height: height / 2,
62-
}),
63-
].filter((rect: DOMRect) => rect.width > 1 && rect.height > 1)
64-
}
65-
66-
const visibleAtPoint = memoize(function (el: HTMLElement, x: number, y: number): boolean {
67-
const elAtPoint = el.ownerDocument.elementFromPoint(x, y)
68-
69-
debug('visibleAtPoint', el, elAtPoint)
70-
71-
return Boolean(elAtPoint) && (elAtPoint === el || el.contains(elAtPoint))
72-
})
73-
74-
function visibleToUser (el: HTMLElement, rect: DOMRect, maxDepth: number = 2, currentDepth: number = 0): boolean {
75-
if (currentDepth >= maxDepth) {
76-
return false
77-
}
78-
79-
const { x, y, width, height } = rect
80-
81-
const samples = [
82-
[x, y],
83-
[x + width, y],
84-
[x, y + height],
85-
[x + width, y + height],
86-
[x + width / 2, y + height / 2],
87-
]
88-
89-
if (samples.some(([x, y]) => visibleAtPoint(el, x, y))) {
90-
debug('some samples are visible')
91-
92-
return true
93-
}
94-
95-
const subRects = subDivideRect(rect)
96-
97-
debug('subRects', subRects)
98-
99-
return subRects.some((subRect: DOMRect) => {
100-
return visibleToUser(el, subRect, maxDepth, currentDepth + 1)
101-
})
102-
}
103-
104-
export function fastIsHidden (subject: JQuery<HTMLElement> | HTMLElement, options: { checkOpacity: boolean } = { checkOpacity: true }): boolean {
105-
debug('fastIsHidden', subject)
106-
10+
export function fastIsHidden (subject: JQuery<HTMLElement> | HTMLElement, options: VisibilityOptions = { checkOpacity: true }): boolean {
10711
if (isBody(subject) || isHTML(subject)) {
10812
return false
10913
}
@@ -126,25 +30,17 @@ export function fastIsHidden (subject: JQuery<HTMLElement> | HTMLElement, option
12630
const select = subject.closest('select')
12731

12832
if (select) {
129-
return fastIsHidden(wrap(select), options)
33+
return fastIsHidden(select, options)
13034
}
13135
}
13236

133-
if (!subject.checkVisibility({
134-
contentVisibilityAuto: true,
135-
opacityProperty: options.checkOpacity,
136-
visibilityProperty: true,
137-
})) {
138-
return true
139-
}
37+
for (const test of VisibilityTests) {
38+
const result = test(subject, options)
14039

141-
const boundingRect = getBoundingClientRect(subject)
142-
143-
if (visibleToUser(subject, boundingRect)) {
144-
debug('visibleToUser', subject, boundingRect)
145-
146-
return false
40+
if (result) {
41+
return result.kind === VisibilityResultKind.HIDDEN
42+
}
14743
}
14844

149-
return true
45+
return false
15046
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { dimensions } from './dimensions'
2+
import { domVisibility } from './domVisibility'
3+
import { pointSampling } from './pointSampling'
4+
5+
export const VisibilityResultKind = Object.freeze({
6+
VISIBLE: 'visible',
7+
HIDDEN: 'hidden',
8+
})
9+
10+
type VisibilityTestResultKind = typeof VisibilityResultKind[keyof typeof VisibilityResultKind]
11+
12+
export interface VisibilityTestResult {
13+
kind: VisibilityTestResultKind
14+
reason: string
15+
}
16+
17+
export interface VisibilityTest {
18+
(el: HTMLElement): VisibilityTestResult | undefined
19+
}
20+
21+
export const VisibilityTests: VisibilityTest[] = [
22+
dimensions,
23+
domVisibility,
24+
pointSampling,
25+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getBoundingClientRect } from './getBoundingClientRect'
2+
import { VisibilityResultKind, VisibilityTestResult } from './VisibilityTest'
3+
4+
export function dimensions (subject: HTMLElement, options: VisibilityOptions): VisibilityTestResult | undefined {
5+
const boundingRect = getBoundingClientRect(subject)
6+
7+
return boundingRect.width === 0 || boundingRect.height === 0 ? VisibilityResultKind.HIDDEN : undefined
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { VisibilityResultKind } from './VisibilityTest'
2+
3+
export function domVisibility (subject: HTMLElement, options: VisibilityOptions): VisibilityTestResult | undefined {
4+
return subject.checkVisibility({
5+
contentVisibilityAuto: true,
6+
opacityProperty: options.checkOpacity,
7+
visibilityProperty: true,
8+
}) ? VisibilityResultKind.VISIBLE : undefined
9+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { memoize } from './memoize'
2+
3+
export const getBoundingClientRect = memoize(
4+
(el: HTMLElement) => el.getBoundingClientRect(),
5+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
interface MemoizeOptions<
2+
IndexFn extends (...args: any[]) => any,
3+
Key = IndexFn extends (...args: any[]) => infer I ? I : never
4+
> {
5+
ttl?: number
6+
key?: (...args: Parameters<T>) => Key
7+
}
8+
9+
export function memoize<T extends (...args: any[]) => any> (
10+
fn: T,
11+
options?: MemoizeOptions<T>): T {
12+
const ttl = options?.ttl ?? 100
13+
const keyFn = options?.key ?? ((args: Parameters<T>) => args[0])
14+
15+
const cache = new Map<ReturnType<typeof keyFn>, { result: ReturnType<T>, timestamp: number }>()
16+
17+
let sweepTimeout: NodeJS.Timeout | undefined
18+
19+
const sweep = () => {
20+
sweepTimeout = undefined
21+
cache.forEach((value, key) => {
22+
if (value.timestamp < Date.now() - ttl) {
23+
cache.delete(key)
24+
}
25+
})
26+
}
27+
28+
return (...args: any[]) => {
29+
sweepTimeout = sweepTimeout || setTimeout(sweep, ttl)
30+
31+
const key = keyFn(args)
32+
const cached = cache.get(key)
33+
34+
if (cached && cached.timestamp > Date.now() - ttl) {
35+
return cached.result
36+
}
37+
38+
const result = fn(...args)
39+
40+
cache.set(key, { result, timestamp: Date.now() })
41+
42+
return result
43+
}
44+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { getBoundingClientRect } from './getBoundingClientRect'
2+
import { memoize } from './memoize'
3+
import { VisibilityResultKind } from './VisibilityTest'
4+
5+
const MAX_DEPTH = 2
6+
const MIN_SIZE = 1
7+
8+
const SUBDIVIDE_PATTERN = [
9+
[0, 0],
10+
[1, 0],
11+
[0, 1],
12+
[1, 1],
13+
] as const
14+
15+
const SAMPLE_POINTS = [
16+
...SUBDIVIDE_PATTERN,
17+
[0.5, 0.5],
18+
] as const
19+
20+
export function pointSampling (subject: HTMLElement, options: VisibilityOptions): VisibilityTestResult | undefined {
21+
const boundingRect = getBoundingClientRect(subject)
22+
23+
return sampleDomRectPoints(subject, boundingRect) ? VisibilityResultKind.VISIBLE : undefined
24+
}
25+
26+
function sampleDomRectPoints (el: HTMLElement, rect: DOMRect, currentDepth: number = 0): boolean {
27+
if (currentDepth >= MAX_DEPTH) {
28+
return false
29+
}
30+
31+
const { x, y, width, height } = rect
32+
33+
const samples = SAMPLE_POINTS.map(([pX, pY]) => [x + pX * width, y + pY * height])
34+
35+
if (samples.some(([x, y]) => visibleAtPoint(el, x, y))) {
36+
return true
37+
}
38+
39+
const subRects = subDivideRect(rect)
40+
41+
debug('subRects', subRects)
42+
43+
return subRects.some((subRect: DOMRect) => {
44+
return visibleToUser(el, subRect, maxDepth, currentDepth + 1)
45+
})
46+
}
47+
48+
function subDivideRect ({ x, y, width, height }: DOMRect): DOMRect[] {
49+
return SUBDIVIDE_PATTERN.map(([dX, dY]) => {
50+
const validDim = width / 2 > MIN_SIZE && height / 2 > MIN_SIZE
51+
52+
return validDim ? DOMRect.fromRect({
53+
x: x + dX * (width / 2),
54+
y: y + dY * (height / 2),
55+
width: width / 2,
56+
height: height / 2,
57+
}) : undefined
58+
}).filter(Boolean)
59+
}
60+
61+
const visibleAtPoint = memoize(function (el: HTMLElement, x: number, y: number): boolean {
62+
const elAtPoint = el.ownerDocument.elementFromPoint(x, y)
63+
64+
debug('visibleAtPoint', el, elAtPoint)
65+
66+
return Boolean(elAtPoint) && (elAtPoint === el || el.contains(elAtPoint))
67+
})

packages/driver/src/dom/visibility/visibleToUser.ts

Whitespace-only changes.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { memoize } from '@packages/driver/src/dom/visibility/memoize'
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3+
4+
describe('memoize', () => {
5+
let fn: vi.Mock
6+
7+
beforeEach(() => {
8+
vi.useFakeTimers()
9+
fn = vi.fn().mockReturnValue('output')
10+
})
11+
12+
afterEach(() => {
13+
vi.clearAllTimers()
14+
vi.useRealTimers()
15+
vi.restoreAllMocks()
16+
})
17+
18+
it('should memoize the result of the function', () => {
19+
const memoizedFn = memoize(fn, { ttl: 1000 })
20+
const result = memoizedFn('input')
21+
22+
expect(fn).toHaveBeenCalledWith('input')
23+
expect(result).toBe('output')
24+
})
25+
26+
it('should return the cached result if the function is called with the same arguments', () => {
27+
const memoizedFn = memoize(fn, { ttl: 1000 })
28+
const result1 = memoizedFn(1)
29+
const result2 = memoizedFn(1)
30+
31+
expect(fn).toHaveBeenCalledWith(1)
32+
expect(fn).toHaveBeenCalledOnce()
33+
expect(result1).toBe('output')
34+
expect(result2).toBe('output')
35+
})
36+
37+
it('should sweep the cache after the ttl', () => {
38+
const memoizedFn = memoize(fn, { ttl: 1000 })
39+
const result1 = memoizedFn('input')
40+
41+
expect(fn).toHaveBeenCalledWith('input')
42+
expect(fn).toHaveBeenCalledTimes(1)
43+
expect(result1).toBe('output')
44+
45+
vi.advanceTimersByTime(1000)
46+
47+
const result3 = memoizedFn('input')
48+
49+
expect(fn).toHaveBeenCalledWith('input')
50+
expect(fn).toHaveBeenCalledTimes(2)
51+
expect(result3).toBe('output')
52+
})
53+
54+
it('supports complex arguments with a custom key function', () => {
55+
const memoizedFn = memoize(fn, { ttl: 1000, key: (args) => JSON.stringify(args) })
56+
const result1 = memoizedFn({ a: 'input-1', b: 'input-2' })
57+
const result2 = memoizedFn({ a: 'input-1', b: 'input-2' })
58+
59+
expect(fn).toHaveBeenCalledWith({ a: 'input-1', b: 'input-2' })
60+
expect(fn).toHaveBeenCalledTimes(1)
61+
expect(result1).toBe('output')
62+
expect(result2).toBe('output')
63+
})
64+
})

0 commit comments

Comments
 (0)