Skip to content

Commit 931a430

Browse files
authored
Merge pull request #1214 from nrkno/fix/virtualElement/sofie-3264
fix: Virtual Element (sofie-3264)
2 parents 9bee355 + bf81baf commit 931a430

File tree

4 files changed

+163
-167
lines changed

4 files changed

+163
-167
lines changed
Lines changed: 149 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,6 @@
1-
import * as React from 'react'
1+
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
22
import { InView } from 'react-intersection-observer'
33

4-
export interface IProps {
5-
initialShow?: boolean
6-
placeholderHeight?: number
7-
_debug?: boolean
8-
placeholderClassName?: string
9-
width?: string | number
10-
margin?: string
11-
id?: string | undefined
12-
className?: string
13-
}
14-
15-
declare global {
16-
interface Window {
17-
requestIdleCallback(
18-
callback: Function,
19-
options?: {
20-
timeout: number
21-
}
22-
): number
23-
cancelIdleCallback(callback: number)
24-
}
25-
}
26-
274
interface IElementMeasurements {
285
width: string | number
296
clientHeight: number
@@ -34,160 +11,172 @@ interface IElementMeasurements {
3411
id: string | undefined
3512
}
3613

37-
interface IState extends IElementMeasurements {
38-
inView: boolean
39-
isMeasured: boolean
40-
}
41-
4214
const OPTIMIZE_PERIOD = 5000
15+
const IDLE_CALLBACK_TIMEOUT = 100
16+
4317
/**
4418
* This is a component that allows optimizing the amount of elements present in the DOM through replacing them
4519
* with placeholders when they aren't visible in the viewport.
4620
*
4721
* @export
48-
* @class VirtualElement
49-
* @extends {React.Component<IProps, IState>}
22+
* @param {(React.PropsWithChildren<{
23+
* initialShow?: boolean
24+
* placeholderHeight?: number
25+
* _debug?: boolean
26+
* placeholderClassName?: string
27+
* width?: string | number
28+
* margin?: string
29+
* id?: string | undefined
30+
* className?: string
31+
* }>)} {
32+
* initialShow,
33+
* placeholderHeight,
34+
* placeholderClassName,
35+
* width,
36+
* margin,
37+
* id,
38+
* className,
39+
* children,
40+
* }
41+
* @return {*} {(JSX.Element | null)}
5042
*/
51-
export class VirtualElement extends React.Component<React.PropsWithChildren<IProps>, IState> {
52-
private el: HTMLElement | null = null
53-
private instance: HTMLElement | null = null
54-
private optimizeTimeout: NodeJS.Timer | null = null
55-
private refreshSizingTimeout: NodeJS.Timer | null = null
56-
private styleObj: CSSStyleDeclaration | undefined
57-
58-
constructor(props: IProps) {
59-
super(props)
60-
this.state = {
61-
inView: props.initialShow || false,
62-
isMeasured: false,
63-
clientHeight: 0,
64-
width: 'auto',
65-
marginBottom: undefined,
66-
marginTop: undefined,
67-
marginLeft: undefined,
68-
marginRight: undefined,
69-
id: undefined,
43+
export function VirtualElement({
44+
initialShow,
45+
placeholderHeight,
46+
placeholderClassName,
47+
width,
48+
margin,
49+
id,
50+
className,
51+
children,
52+
}: React.PropsWithChildren<{
53+
initialShow?: boolean
54+
placeholderHeight?: number
55+
_debug?: boolean
56+
placeholderClassName?: string
57+
width?: string | number
58+
margin?: string
59+
id?: string | undefined
60+
className?: string
61+
}>): JSX.Element | null {
62+
const [inView, setInView] = useState(initialShow ?? false)
63+
const [isShowingChildren, setIsShowingChildren] = useState(inView)
64+
const [measurements, setMeasurements] = useState<IElementMeasurements | null>(null)
65+
const [ref, setRef] = useState<HTMLDivElement | null>(null)
66+
const [childRef, setChildRef] = useState<HTMLElement | null>(null)
67+
68+
const isMeasured = !!measurements
69+
70+
const styleObj = useMemo<React.CSSProperties>(
71+
() => ({
72+
width: width ?? measurements?.width ?? 'auto',
73+
height: (measurements?.clientHeight ?? placeholderHeight ?? '0') + 'px',
74+
marginTop: measurements?.marginTop,
75+
marginLeft: measurements?.marginLeft,
76+
marginRight: measurements?.marginRight,
77+
marginBottom: measurements?.marginBottom,
78+
}),
79+
[width, measurements, placeholderHeight]
80+
)
81+
82+
const onVisibleChanged = useCallback((visible: boolean) => {
83+
setInView(visible)
84+
}, [])
85+
86+
useEffect(() => {
87+
if (inView === true) {
88+
setIsShowingChildren(true)
89+
return
7090
}
71-
}
7291

73-
private visibleChanged = (inView: boolean) => {
74-
this.props._debug && console.log(this.props.id, 'Changed', inView)
75-
if (this.optimizeTimeout) {
76-
clearTimeout(this.optimizeTimeout)
77-
this.optimizeTimeout = null
78-
}
79-
if (inView && !this.state.inView) {
80-
this.setState({
81-
inView,
82-
})
83-
} else if (!inView && this.state.inView) {
84-
this.optimizeTimeout = setTimeout(() => {
85-
this.optimizeTimeout = null
86-
const measurements = this.measureElement() || undefined
87-
this.setState({
88-
inView,
89-
90-
isMeasured: measurements ? true : false,
91-
...measurements,
92-
} as IState)
93-
}, OPTIMIZE_PERIOD)
94-
}
95-
}
92+
let idleCallback: number | undefined
93+
const optimizeTimeout = window.setTimeout(() => {
94+
idleCallback = window.requestIdleCallback(
95+
() => {
96+
if (childRef) {
97+
setMeasurements(measureElement(childRef))
98+
}
99+
setIsShowingChildren(false)
100+
},
101+
{
102+
timeout: IDLE_CALLBACK_TIMEOUT,
103+
}
104+
)
105+
}, OPTIMIZE_PERIOD)
96106

97-
private measureElement = (): IElementMeasurements | null => {
98-
if (this.el) {
99-
const style = this.styleObj || window.getComputedStyle(this.el)
100-
this.styleObj = style
101-
this.props._debug && console.log(this.props.id, 'Re-measuring child', this.el.clientHeight)
102-
103-
return {
104-
width: style.width || 'auto',
105-
clientHeight: this.el.clientHeight,
106-
marginTop: style.marginTop || undefined,
107-
marginBottom: style.marginBottom || undefined,
108-
marginLeft: style.marginLeft || undefined,
109-
marginRight: style.marginRight || undefined,
110-
id: this.el.id,
107+
return () => {
108+
if (idleCallback) {
109+
window.cancelIdleCallback(idleCallback)
111110
}
112-
}
113-
114-
return null
115-
}
116111

117-
private refreshSizing = () => {
118-
this.refreshSizingTimeout = null
119-
const measurements = this.measureElement()
120-
if (measurements) {
121-
this.setState({
122-
isMeasured: true,
123-
...measurements,
124-
})
112+
window.clearTimeout(optimizeTimeout)
125113
}
126-
}
114+
}, [childRef, inView])
127115

128-
private findChildElement = () => {
129-
if (!this.el || !this.el.parentElement) {
130-
const el = this.instance ? (this.instance.firstElementChild as HTMLElement) : null
131-
if (el && !el.classList.contains('virtual-element-placeholder')) {
132-
this.el = el
133-
this.styleObj = undefined
134-
this.refreshSizingTimeout = setTimeout(this.refreshSizing, 250)
135-
}
136-
}
137-
}
116+
const showPlaceholder = !isShowingChildren && (!initialShow || isMeasured)
138117

139-
private setRef = (instance: HTMLElement | null) => {
140-
this.instance = instance
141-
this.findChildElement()
142-
}
118+
useLayoutEffect(() => {
119+
if (!ref || showPlaceholder) return
143120

144-
componentDidUpdate(_: IProps, prevState: IState): void {
145-
if (this.state.inView && prevState.inView !== this.state.inView) {
146-
this.findChildElement()
147-
}
148-
}
121+
const el = ref?.firstElementChild
122+
if (!el || el.classList.contains('virtual-element-placeholder') || !(el instanceof HTMLElement)) return
149123

150-
componentWillUnmount(): void {
151-
if (this.optimizeTimeout) clearTimeout(this.optimizeTimeout)
152-
if (this.refreshSizingTimeout) clearTimeout(this.refreshSizingTimeout)
153-
}
124+
setChildRef(el)
154125

155-
render(): JSX.Element {
156-
this.props._debug &&
157-
console.log(
158-
this.props.id,
159-
this.state.inView,
160-
this.props.initialShow,
161-
this.state.isMeasured,
162-
!this.state.inView && (!this.props.initialShow || this.state.isMeasured)
126+
let idleCallback: number | undefined
127+
const refreshSizingTimeout = window.setTimeout(() => {
128+
idleCallback = window.requestIdleCallback(
129+
() => {
130+
setMeasurements(measureElement(el))
131+
},
132+
{
133+
timeout: IDLE_CALLBACK_TIMEOUT,
134+
}
163135
)
164-
return (
165-
<InView
166-
threshold={0}
167-
rootMargin={this.props.margin || '50% 0px 50% 0px'}
168-
onChange={this.visibleChanged}
169-
className={this.props.className}
170-
as="div"
171-
>
172-
<div ref={this.setRef}>
173-
{!this.state.inView && (!this.props.initialShow || this.state.isMeasured) ? (
174-
<div
175-
id={this.state.id || this.props.id}
176-
className={'virtual-element-placeholder ' + (this.props.placeholderClassName || '')}
177-
style={{
178-
width: this.props.width || this.state.width,
179-
height: (this.state.clientHeight || this.props.placeholderHeight || '0') + 'px',
180-
marginTop: this.state.marginTop,
181-
marginLeft: this.state.marginLeft,
182-
marginRight: this.state.marginRight,
183-
marginBottom: this.state.marginBottom,
184-
}}
185-
></div>
186-
) : (
187-
this.props.children
188-
)}
189-
</div>
190-
</InView>
191-
)
136+
}, 1000)
137+
138+
return () => {
139+
if (idleCallback) {
140+
window.cancelIdleCallback(idleCallback)
141+
}
142+
window.clearTimeout(refreshSizingTimeout)
143+
}
144+
}, [ref, showPlaceholder])
145+
146+
return (
147+
<InView
148+
threshold={0}
149+
rootMargin={margin || '50% 0px 50% 0px'}
150+
onChange={onVisibleChanged}
151+
className={className}
152+
as="div"
153+
>
154+
<div ref={setRef}>
155+
{showPlaceholder ? (
156+
<div
157+
id={measurements?.id ?? id}
158+
className={`virtual-element-placeholder ${placeholderClassName}`}
159+
style={styleObj}
160+
></div>
161+
) : (
162+
children
163+
)}
164+
</div>
165+
</InView>
166+
)
167+
}
168+
169+
function measureElement(el: HTMLElement): IElementMeasurements | null {
170+
const style = window.getComputedStyle(el)
171+
const clientRect = el.getBoundingClientRect()
172+
173+
return {
174+
width: style.width || 'auto',
175+
clientHeight: clientRect.height,
176+
marginTop: style.marginTop || undefined,
177+
marginBottom: style.marginBottom || undefined,
178+
marginLeft: style.marginLeft || undefined,
179+
marginRight: style.marginRight || undefined,
180+
id: el.id,
192181
}
193182
}

meteor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"react-dom": "^18.2.0",
9494
"react-hotkeys": "^2.0.0",
9595
"react-i18next": "^11.18.6",
96-
"react-intersection-observer": "^9.4.3",
96+
"react-intersection-observer": "^9.10.3",
9797
"react-moment": "^0.9.7",
9898
"react-popper": "^2.2.5",
9999
"react-router-dom": "^5.3.3",

meteor/yarn.lock

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3558,7 +3558,7 @@ __metadata:
35583558
react-dom: ^18.2.0
35593559
react-hotkeys: ^2.0.0
35603560
react-i18next: ^11.18.6
3561-
react-intersection-observer: ^9.4.3
3561+
react-intersection-observer: ^9.10.3
35623562
react-moment: ^0.9.7
35633563
react-popper: ^2.2.5
35643564
react-router-dom: ^5.3.3
@@ -10856,12 +10856,16 @@ __metadata:
1085610856
languageName: node
1085710857
linkType: hard
1085810858

10859-
"react-intersection-observer@npm:^9.4.3":
10860-
version: 9.4.3
10861-
resolution: "react-intersection-observer@npm:9.4.3"
10859+
"react-intersection-observer@npm:^9.10.3":
10860+
version: 9.10.3
10861+
resolution: "react-intersection-observer@npm:9.10.3"
1086210862
peerDependencies:
10863-
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
10864-
checksum: ac31c6c76ce72019a1fb50fe6b53ef6429d98b9e9b937f92d16635ef8586392c7058bb61526a8fe6bea6ce36c006015fe2d8893bac43eda6157b9e6b17ad9b68
10863+
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
10864+
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
10865+
peerDependenciesMeta:
10866+
react-dom:
10867+
optional: true
10868+
checksum: 482c89a432e582749f3cb3dd696e08638a92e41fbcb81bcb3dc3cadebcf8b40bc47e7a52d2a7e8c4f9eb2a3c1c29b4cb0f21007c1540da05893b5abb11d7a761
1086510869
languageName: node
1086610870
linkType: hard
1086710871

tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "./meteor/tsconfig.json",
3+
}

0 commit comments

Comments
 (0)