Skip to content

Commit

Permalink
fix: refactor VirtualElement to be a FC
Browse files Browse the repository at this point in the history
(cherry picked from commit bf81baf)
  • Loading branch information
jstarpl committed Jun 26, 2024
1 parent 87065d7 commit 0dd6a1f
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 169 deletions.
309 changes: 149 additions & 160 deletions meteor/client/lib/VirtualElement.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,6 @@
import * as React from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { InView } from 'react-intersection-observer'

export interface IProps {
initialShow?: boolean
placeholderHeight?: number
_debug?: boolean
placeholderClassName?: string
width?: string | number
margin?: string
id?: string | undefined
className?: string
}

declare global {
interface Window {
requestIdleCallback(
callback: Function,
options?: {
timeout: number
}
): number
cancelIdleCallback(callback: number): void
}
}

interface IElementMeasurements {
width: string | number
clientHeight: number
Expand All @@ -34,160 +11,172 @@ interface IElementMeasurements {
id: string | undefined
}

interface IState extends IElementMeasurements {
inView: boolean
isMeasured: boolean
}

const OPTIMIZE_PERIOD = 5000
const IDLE_CALLBACK_TIMEOUT = 100

/**
* This is a component that allows optimizing the amount of elements present in the DOM through replacing them
* with placeholders when they aren't visible in the viewport.
*
* @export
* @class VirtualElement
* @extends {React.Component<IProps, IState>}
* @param {(React.PropsWithChildren<{
* initialShow?: boolean
* placeholderHeight?: number
* _debug?: boolean
* placeholderClassName?: string
* width?: string | number
* margin?: string
* id?: string | undefined
* className?: string
* }>)} {
* initialShow,
* placeholderHeight,
* placeholderClassName,
* width,
* margin,
* id,
* className,
* children,
* }
* @return {*} {(JSX.Element | null)}
*/
export class VirtualElement extends React.Component<React.PropsWithChildren<IProps>, IState> {
private el: HTMLElement | null = null
private instance: HTMLElement | null = null
private optimizeTimeout: NodeJS.Timer | null = null
private refreshSizingTimeout: NodeJS.Timer | null = null
private styleObj: CSSStyleDeclaration | undefined

constructor(props: IProps) {
super(props)
this.state = {
inView: props.initialShow || false,
isMeasured: false,
clientHeight: 0,
width: 'auto',
marginBottom: undefined,
marginTop: undefined,
marginLeft: undefined,
marginRight: undefined,
id: undefined,
export function VirtualElement({
initialShow,
placeholderHeight,
placeholderClassName,
width,
margin,
id,
className,
children,
}: React.PropsWithChildren<{
initialShow?: boolean
placeholderHeight?: number
_debug?: boolean
placeholderClassName?: string
width?: string | number
margin?: string
id?: string | undefined
className?: string
}>): JSX.Element | null {
const [inView, setInView] = useState(initialShow ?? false)
const [isShowingChildren, setIsShowingChildren] = useState(inView)
const [measurements, setMeasurements] = useState<IElementMeasurements | null>(null)
const [ref, setRef] = useState<HTMLDivElement | null>(null)
const [childRef, setChildRef] = useState<HTMLElement | null>(null)

const isMeasured = !!measurements

const styleObj = useMemo<React.CSSProperties>(
() => ({
width: width ?? measurements?.width ?? 'auto',
height: (measurements?.clientHeight ?? placeholderHeight ?? '0') + 'px',
marginTop: measurements?.marginTop,
marginLeft: measurements?.marginLeft,
marginRight: measurements?.marginRight,
marginBottom: measurements?.marginBottom,
}),
[width, measurements, placeholderHeight]
)

const onVisibleChanged = useCallback((visible: boolean) => {
setInView(visible)
}, [])

useEffect(() => {
if (inView === true) {
setIsShowingChildren(true)
return
}
}

private visibleChanged = (inView: boolean) => {
this.props._debug && console.log(this.props.id, 'Changed', inView)
if (this.optimizeTimeout) {
clearTimeout(this.optimizeTimeout)
this.optimizeTimeout = null
}
if (inView && !this.state.inView) {
this.setState({
inView,
})
} else if (!inView && this.state.inView) {
this.optimizeTimeout = setTimeout(() => {
this.optimizeTimeout = null
const measurements = this.measureElement() || undefined
this.setState({
inView,

isMeasured: measurements ? true : false,
...measurements,
} as IState)
}, OPTIMIZE_PERIOD)
}
}
let idleCallback: number | undefined
const optimizeTimeout = window.setTimeout(() => {
idleCallback = window.requestIdleCallback(
() => {
if (childRef) {
setMeasurements(measureElement(childRef))
}
setIsShowingChildren(false)
},
{
timeout: IDLE_CALLBACK_TIMEOUT,
}
)
}, OPTIMIZE_PERIOD)

private measureElement = (): IElementMeasurements | null => {
if (this.el) {
const style = this.styleObj || window.getComputedStyle(this.el)
this.styleObj = style
this.props._debug && console.log(this.props.id, 'Re-measuring child', this.el.clientHeight)

return {
width: style.width || 'auto',
clientHeight: this.el.clientHeight,
marginTop: style.marginTop || undefined,
marginBottom: style.marginBottom || undefined,
marginLeft: style.marginLeft || undefined,
marginRight: style.marginRight || undefined,
id: this.el.id,
return () => {
if (idleCallback) {
window.cancelIdleCallback(idleCallback)
}
}

return null
}

private refreshSizing = () => {
this.refreshSizingTimeout = null
const measurements = this.measureElement()
if (measurements) {
this.setState({
isMeasured: true,
...measurements,
})
window.clearTimeout(optimizeTimeout)
}
}
}, [childRef, inView])

private findChildElement = () => {
if (!this.el || !this.el.parentElement) {
const el = this.instance ? (this.instance.firstElementChild as HTMLElement) : null
if (el && !el.classList.contains('virtual-element-placeholder')) {
this.el = el
this.styleObj = undefined
this.refreshSizingTimeout = setTimeout(this.refreshSizing, 250)
}
}
}
const showPlaceholder = !isShowingChildren && (!initialShow || isMeasured)

private setRef = (instance: HTMLElement | null) => {
this.instance = instance
this.findChildElement()
}
useLayoutEffect(() => {
if (!ref || showPlaceholder) return

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

componentWillUnmount(): void {
if (this.optimizeTimeout) clearTimeout(this.optimizeTimeout)
if (this.refreshSizingTimeout) clearTimeout(this.refreshSizingTimeout)
}
setChildRef(el)

render(): JSX.Element {
this.props._debug &&
console.log(
this.props.id,
this.state.inView,
this.props.initialShow,
this.state.isMeasured,
!this.state.inView && (!this.props.initialShow || this.state.isMeasured)
let idleCallback: number | undefined
const refreshSizingTimeout = window.setTimeout(() => {
idleCallback = window.requestIdleCallback(
() => {
setMeasurements(measureElement(el))
},
{
timeout: IDLE_CALLBACK_TIMEOUT,
}
)
return (
<InView
threshold={0}
rootMargin={this.props.margin || '50% 0px 50% 0px'}
onChange={this.visibleChanged}
className={this.props.className}
as="div"
>
<div ref={this.setRef}>
{!this.state.inView && (!this.props.initialShow || this.state.isMeasured) ? (
<div
id={this.state.id || this.props.id}
className={'virtual-element-placeholder ' + (this.props.placeholderClassName || '')}
style={{
width: this.props.width || this.state.width,
height: (this.state.clientHeight || this.props.placeholderHeight || '0') + 'px',
marginTop: this.state.marginTop,
marginLeft: this.state.marginLeft,
marginRight: this.state.marginRight,
marginBottom: this.state.marginBottom,
}}
></div>
) : (
this.props.children
)}
</div>
</InView>
)
}, 1000)

return () => {
if (idleCallback) {
window.cancelIdleCallback(idleCallback)
}
window.clearTimeout(refreshSizingTimeout)
}
}, [ref, showPlaceholder])

return (
<InView
threshold={0}
rootMargin={margin || '50% 0px 50% 0px'}
onChange={onVisibleChanged}
className={className}
as="div"
>
<div ref={setRef}>
{showPlaceholder ? (
<div
id={measurements?.id ?? id}
className={`virtual-element-placeholder ${placeholderClassName}`}
style={styleObj}
></div>
) : (
children
)}
</div>
</InView>
)
}

function measureElement(el: HTMLElement): IElementMeasurements | null {
const style = window.getComputedStyle(el)
const clientRect = el.getBoundingClientRect()

return {
width: style.width || 'auto',
clientHeight: clientRect.height,
marginTop: style.marginTop || undefined,
marginBottom: style.marginBottom || undefined,
marginLeft: style.marginLeft || undefined,
marginRight: style.marginRight || undefined,
id: el.id,
}
}
4 changes: 2 additions & 2 deletions meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"react-focus-bounder": "^1.1.6",
"react-hotkeys": "^2.0.0",
"react-i18next": "^11.18.6",
"react-intersection-observer": "^9.6.0",
"react-intersection-observer": "^9.10.3",
"react-moment": "^0.9.7",
"react-popper": "^2.3.0",
"react-router-dom": "^5.3.4",
Expand Down Expand Up @@ -207,4 +207,4 @@
"@sofie-automation/shared-lib": "portal:../packages/shared-lib"
},
"packageManager": "yarn@3.5.0"
}
}
14 changes: 7 additions & 7 deletions meteor/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2786,7 +2786,7 @@ __metadata:
react-focus-bounder: ^1.1.6
react-hotkeys: ^2.0.0
react-i18next: ^11.18.6
react-intersection-observer: ^9.6.0
react-intersection-observer: ^9.10.3
react-moment: ^0.9.7
react-popper: ^2.3.0
react-router-dom: ^5.3.4
Expand Down Expand Up @@ -10149,16 +10149,16 @@ __metadata:
languageName: node
linkType: hard

"react-intersection-observer@npm:^9.6.0":
version: 9.6.0
resolution: "react-intersection-observer@npm:9.6.0"
"react-intersection-observer@npm:^9.10.3":
version: 9.10.3
resolution: "react-intersection-observer@npm:9.10.3"
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react-dom:
optional: true
checksum: fba57f1601b6c08ea0345de23c4e41b1e0042834dbef62fa419fc18952f9ceb577d180ef3d8c8e6b01c3d5cfc3265ae5a007a532892c376461bf655b0c764ef7
checksum: 482c89a432e582749f3cb3dd696e08638a92e41fbcb81bcb3dc3cadebcf8b40bc47e7a52d2a7e8c4f9eb2a3c1c29b4cb0f21007c1540da05893b5abb11d7a761
languageName: node
linkType: hard

Expand Down
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./meteor/tsconfig.json",
}

0 comments on commit 0dd6a1f

Please sign in to comment.