1
- import * as React from 'react'
1
+ import React , { useCallback , useEffect , useLayoutEffect , useMemo , useState } from 'react'
2
2
import { InView } from 'react-intersection-observer'
3
3
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
-
27
4
interface IElementMeasurements {
28
5
width : string | number
29
6
clientHeight : number
@@ -34,160 +11,172 @@ interface IElementMeasurements {
34
11
id : string | undefined
35
12
}
36
13
37
- interface IState extends IElementMeasurements {
38
- inView : boolean
39
- isMeasured : boolean
40
- }
41
-
42
14
const OPTIMIZE_PERIOD = 5000
15
+ const IDLE_CALLBACK_TIMEOUT = 100
16
+
43
17
/**
44
18
* This is a component that allows optimizing the amount of elements present in the DOM through replacing them
45
19
* with placeholders when they aren't visible in the viewport.
46
20
*
47
21
* @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)}
50
42
*/
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
70
90
}
71
- }
72
91
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 )
96
106
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 )
111
110
}
112
- }
113
-
114
- return null
115
- }
116
111
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 )
125
113
}
126
- }
114
+ } , [ childRef , inView ] )
127
115
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 )
138
117
139
- private setRef = ( instance : HTMLElement | null ) => {
140
- this . instance = instance
141
- this . findChildElement ( )
142
- }
118
+ useLayoutEffect ( ( ) => {
119
+ if ( ! ref || showPlaceholder ) return
143
120
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
149
123
150
- componentWillUnmount ( ) : void {
151
- if ( this . optimizeTimeout ) clearTimeout ( this . optimizeTimeout )
152
- if ( this . refreshSizingTimeout ) clearTimeout ( this . refreshSizingTimeout )
153
- }
124
+ setChildRef ( el )
154
125
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
+ }
163
135
)
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 ,
192
181
}
193
182
}
0 commit comments