Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pr/layout performance #640

Merged
merged 2 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build/api/react-utils.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export type DebugMethod = (...parameters: any[]) => void;
// @public (undocumented)
export const DEFAULT_COLOR_SCHEME = "system";

// @public (undocumented)
export type ElementSize = {
height: number;
width: number;
};

// @public (undocumented)
export const emptyArray: any[];

Expand Down
6 changes: 6 additions & 0 deletions build/api/utilities.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export function filterThemedClassName(nestedClassName: NestedClassName, defaultC
// @public (undocumented)
export function flatClassNameList(className: NestedClassName): string[];

// @public
export function getElementDimensions(element: HTMLElement): Promise<DOMRectReadOnly>;

// @public
export function getElementDimensionsCallback(element: HTMLElement, callback: (dimensions: DOMRectReadOnly) => void): void;

// @public (undocumented)
export function getMatchingParentElement(element: HTMLElement | null, predicate: (element: HTMLElement | null) => boolean | Promise<boolean>): HTMLElement;

Expand Down
21 changes: 13 additions & 8 deletions packages/layout/src/insets/SafeAreaInsetsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useExpectSameValueReference, useOnWindowResize, useReferentiallyStableCallback } from '@contember/react-utils'
import { getElementDimensionsCallback } from '@contember/utilities'
import deepEqual from 'fast-deep-equal/es6/index.js'
import { ReactNode, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SafeAreaInsetsContext, useContainerInsetsContext } from './Contexts'
Expand Down Expand Up @@ -28,16 +29,20 @@ export const SafeAreaInsetsProvider = memo(({ children, insets: insetsProp }: Sa
const [rects, setRects] = useState(getInitialScreenRectsState())
const nativeInsetsElementRef = useRef<HTMLDivElement>(null)

const maybeSetNewRects = useReferentiallyStableCallback((safeArea: DOMRectReadOnly) => {
const next = {
screen: getScreenInnerBoundingRect(),
safeArea,
}

if (!deepEqual(next, rects)) {
setRects(next)
}
})

const getNativeInsets = useReferentiallyStableCallback(() => {
if (nativeInsetsElementRef.current) {
const next = {
screen: getScreenInnerBoundingRect(),
safeArea: nativeInsetsElementRef.current.getBoundingClientRect(),
}

if (!deepEqual(next, rects)) {
setRects(next)
}
getElementDimensionsCallback(nativeInsetsElementRef.current, maybeSetNewRects)
}
})

Expand Down
80 changes: 41 additions & 39 deletions packages/layout/src/insets/useElementInsets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useOnElementResize, useReferentiallyStableCallback, useScopedConsoleRef } from '@contember/react-utils'
import { assert, getSizeFromResizeObserverEntryFactory, isHTMLElement, pick } from '@contember/utilities'
import { assert, getElementDimensions, getSizeFromResizeObserverEntryFactory, isHTMLElement, pick } from '@contember/utilities'
import { CSSProperties, RefObject, useEffect, useMemo, useState } from 'react'
import { useContainerInsetsContext } from './Contexts'
import { getElementInsets } from './Helpers'
Expand All @@ -14,28 +14,23 @@ export function useElementInsets(elementRef: RefObject<HTMLElement>) {
const containerInsets = useContainerInsetsContext()
const [containerOffsets, setContainerOffsets] = useState<ContainerOffsets>()

const updateContainerOffsets = useReferentiallyStableCallback((dimensions?: ReturnType<typeof dimensionsFromEntry>) => {
const updateContainerOffsets = useReferentiallyStableCallback(async (dimensions?: ReturnType<typeof dimensionsFromEntry>) => {
if (elementRef.current) {
const { width, height } = dimensions ?? {
width: elementRef.current?.offsetWidth,
height: elementRef.current?.offsetHeight,
}
dimensions = dimensions ?? await getElementDimensions(elementRef.current)

const { width, height } = dimensions

if (height && width) {
const element = logged('element', elementRef.current)
const parentElement = logged('parentElement', element.parentElement)

if (parentElement) {
log('window', pick(window, ['innerWidth', 'innerHeight', 'scrollX', 'scrollY']))
log('parentElement', parentElement, pick(parentElement, ['offsetWidth', 'offsetHeight', 'offsetLeft', 'offsetTop']))
log('element', element, pick(element, ['offsetWidth', 'offsetHeight', 'offsetLeft', 'offsetTop']))

const parentProps = logged('Parent props:', pickLayoutProperties(parentElement))
const elementProps = logged('elementProps', pickLayoutProperties(element, parentProps.position as CSSProperties['position']))
const parentProps = logged('Parent props:', await pickLayoutProperties(parentElement))
const elementProps = logged('elementProps', await pickLayoutProperties(element, parentProps.position as CSSProperties['position']))

if (parentProps.display === 'flex') {
const previous = logged('Previous element sibling with layout:', getPreviousElementSiblingWithLayout(element))
const next = logged('Next element sibling with layout:', getNextElementSiblingWithLayout(element))
const previous = logged('Previous element sibling with layout:', await getPreviousElementSiblingWithLayout(element))
const next = logged('Next element sibling with layout:', await getNextElementSiblingWithLayout(element))

let offsets = logged('calculated offsets', parentProps.position === 'static' ? {
offsetBottom: Math.max(0, parentProps.scrollHeight - (parentProps.offsetTop - elementProps.offsetTop) - elementProps.offsetHeight),
Expand Down Expand Up @@ -113,71 +108,78 @@ export function useElementInsets(elementRef: RefObject<HTMLElement>) {
return logged('elementInsets', elementInsets)
}

function getPreviousElementSiblingWithLayout(element: HTMLElement): HTMLElement | null {
async function getPreviousElementSiblingWithLayout(element: HTMLElement): Promise<HTMLElement | null> {
const sibling = element.previousElementSibling

if (sibling) {
assert('sibling is isHTMLElement', sibling, isHTMLElement)

const { offsetWidth, offsetHeight } = sibling
const { width, height } = await getElementDimensions(sibling)

if (offsetWidth && offsetHeight && sibling.offsetParent) {
if (width && height && sibling.offsetParent) {
return sibling
} else {
return getPreviousElementSiblingWithLayout(sibling)
return await getPreviousElementSiblingWithLayout(sibling)
}
} else {
return null
}
}

function getNextElementSiblingWithLayout(element: HTMLElement): HTMLElement | null {
async function getNextElementSiblingWithLayout(element: HTMLElement): Promise<HTMLElement | null> {
const sibling = element.nextElementSibling

if (sibling) {
assert('sibling is isHTMLElement', sibling, isHTMLElement)

const { offsetWidth, offsetHeight } = sibling
const { width, height } = await getElementDimensions(sibling)

if (offsetWidth && offsetHeight && sibling.offsetParent) {
if (width && height && sibling.offsetParent) {
return sibling
} else {
return getNextElementSiblingWithLayout(sibling)
return await getNextElementSiblingWithLayout(sibling)
}
} else {
return null
}
}

function pickLayoutProperties(element: HTMLElement, parentPosition?: CSSProperties['position']) {
const { display, flexDirection, position } = getComputedStyle(element)
async function pickLayoutProperties(element: HTMLElement, parentPosition?: CSSProperties['position']) {
const { display, flexDirection, position } = await getComputedStyle(element)

return {
display,
flexDirection,
position,
...(position === 'sticky' && (!parentPosition || parentPosition === 'static') ? stickyPositionOffsets(element) : pick(element, [
'offsetHeight',
'offsetLeft',
'offsetParent',
'offsetTop',
'offsetWidth',
])),
...(position === 'sticky' && (!parentPosition || parentPosition === 'static')
? await getStickyElementOffsets(element)
: await getElementOffsets(element)),
...pick(element, [
'scrollHeight',
'scrollWidth',
]),
}
}

function stickyPositionOffsets(element: HTMLElement) {
const properties = pick(element, [
'offsetHeight',
'offsetLeft',
'offsetParent',
'offsetTop',
'offsetWidth',
])
async function getElementOffsets(element: HTMLElement) {
const {
height: offsetHeight,
left: offsetLeft,
top: offsetTop,
width: offsetWidth,
} = await element.getBoundingClientRect()

return {
offsetHeight,
offsetLeft,
offsetTop,
offsetWidth,
offsetParent: element.offsetParent,
}
}

async function getStickyElementOffsets(element: HTMLElement) {
const properties = await getElementOffsets(element)

return {
...properties,
Expand Down
6 changes: 0 additions & 6 deletions packages/react-utils/src/hooks/useAddClassNameDuringResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ import { useLayoutEffect, useRef } from 'react'
import { useReferentiallyStableCallback } from '../referentiallyStable'
import { useOnWindowResize } from './useOnWindowResize'

function intendedFlushOfCSSChangesToCauseImmediateReflow(element: HTMLElement) {
element.offsetHeight
}

function addClassName(
className: string,
element: HTMLElement,
) {
if (element.classList.contains(className)) {
element.classList.remove(className)
intendedFlushOfCSSChangesToCauseImmediateReflow(element)
}
}

Expand All @@ -22,7 +17,6 @@ function removeClassName(
) {
if (!element.classList.contains(className)) {
element.classList.add(className)
intendedFlushOfCSSChangesToCauseImmediateReflow(element)
}
}

Expand Down
10 changes: 5 additions & 5 deletions packages/react-utils/src/hooks/useAutoHeightTextArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const useAutoHeightTextArea = (
minRows: number,
maxRows: number,
) => {
const measure = useCallback((ref: HTMLTextAreaElement | null, minRows: number, maxRows: number, value: string) => {
const measure = useCallback(async (ref: HTMLTextAreaElement | null, minRows: number, maxRows: number) => {
if (ref) {
const rowsAttribute = ref.rows

Expand All @@ -23,13 +23,13 @@ export const useAutoHeightTextArea = (
let maxHeight: number

ref.rows = minRows
minHeight = ref.offsetHeight
minHeight = await ref.getBoundingClientRect().height

if (maxRows === Infinity) {
maxHeight = Infinity
} else {
ref.rows = maxRows
maxHeight = ref.offsetHeight
maxHeight = await ref.getBoundingClientRect().height
}

ref.style.height = px(height)
Expand All @@ -42,10 +42,10 @@ export const useAutoHeightTextArea = (
}, [])

useOnElementResize(textAreaRef, () => {
measure(unwrapRefValue(textAreaRef), minRows, maxRows, value)
measure(unwrapRefValue(textAreaRef), minRows, maxRows)
})

useLayoutEffect(() => {
measure(unwrapRefValue(textAreaRef), minRows, maxRows, value)
measure(unwrapRefValue(textAreaRef), minRows, maxRows)
}, [maxRows, measure, minRows, textAreaRef, value])
}
51 changes: 17 additions & 34 deletions packages/react-utils/src/hooks/useElementSize.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { getSizeFromResizeObserverEntryFactory } from '@contember/utilities'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { getElementDimensionsCallback, getSizeFromResizeObserverEntryFactory } from '@contember/utilities'
import { useLayoutEffect, useMemo, useState } from 'react'
import { useScopedConsoleRef } from '../debug-context'
import { useReferentiallyStableCallback } from '../referentiallyStable'
import { RefObjectOrElement, unwrapRefValue } from './unwrapRefValue'
import { useOnElementResize } from './useOnElementResize'

function getElementWidth(element: HTMLElement) {
return element.offsetWidth
}

function getElementHeight(element: HTMLElement) {
return element.offsetHeight
export type ElementSize = {
height: number
width: number
}

/**
Expand All @@ -28,45 +26,30 @@ export function useElementSize(
const { box = 'border-box' } = options
const getSizeFromResizeObserverEntry = useMemo(() => getSizeFromResizeObserverEntryFactory(box), [box])

const refValue = unwrapRefValue(refOrElement)
const [width, setWidth] = useState<number | undefined>(refValue?.offsetWidth)
const [height, setHeight] = useState<number | undefined>(refValue?.offsetHeight)
const [width, setWidth] = useState<number | undefined>()
const [height, setHeight] = useState<number | undefined>()

logged('dimensions', { width, height })

useOnElementResize(refOrElement, entry => {
const dimensions = logged('useOnElementResize => entry:dimensions', getSizeFromResizeObserverEntry(entry))

const maybeSetNewDimensions = useReferentiallyStableCallback((dimensions: ElementSize) => {
if (width !== dimensions.width) {
setWidth(dimensions.width)
}
if (height !== dimensions.height) {
setHeight(dimensions.height)
}
}, { box }, timeout)

// INTENTIONAL AVOID OF ESLint ERROR: We need measure on every Layout side-effect
// otherwise the browser will paint and cause layout shifts.
//
// React Hook useLayoutEffect contains a call to 'setWidth'. Without a list of dependencies,
// this can lead to an infinite chain of updates. To fix this, pass [element] as a second argument
// to the useLayoutEffect Hook.
//
// Seems like only pure function lets us measure before the browser has a chance to paint
const measure = useRef((element: HTMLElement | null) => {
logged('measure(element)', element)

if (element instanceof HTMLElement) {
setWidth(logged('getElementWidth(element):', getElementWidth(element)))
setHeight(logged('getElementHeight(element):', getElementHeight(element)))
} else if (element) {
throw new Error('Exhaustive error: Expecting element to be instance of HTMLElement or Window')
}
})

useOnElementResize(refOrElement, entry => {
const dimensions = logged('useOnElementResize => entry:dimensions', getSizeFromResizeObserverEntry(entry))
maybeSetNewDimensions(dimensions)
}, { box }, timeout)

useLayoutEffect(() => {
const element = unwrapRefValue(refOrElement)
logged('useLayoutEffect => measure', measure.current(element))
if (element) {
getElementDimensionsCallback(element, dimensions => maybeSetNewDimensions(dimensions))
}
})

return { height, width }
Expand Down
13 changes: 6 additions & 7 deletions packages/react-utils/src/hooks/useScrollOffsets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assert } from '@contember/utilities'
import { assert, getElementDimensions } from '@contember/utilities'
import { useLayoutEffect, useMemo, useState } from 'react'
import { useScopedConsoleRef } from '../debug-context'
import { useReferentiallyStableCallback } from '../referentiallyStable'
Expand Down Expand Up @@ -37,9 +37,7 @@ export function useScrollOffsets(refOrElement: RefObjectOrElement<HTMLElement |
const maybeScrollContent = unwrapRefValue(refOrElement) ?? document

if (maybeScrollContent) {
updateScrollOffsetsState(
getElementScrollOffsets(maybeScrollContent),
)
getElementScrollOffsets(maybeScrollContent).then(updateScrollOffsetsState)
}
})

Expand Down Expand Up @@ -69,14 +67,15 @@ function getIntrinsicScrollContainer(element: HTMLElement | Document): HTMLEleme
}
}

function getElementScrollOffsets(element: HTMLElement | Document): Offsets {
async function getElementScrollOffsets(element: HTMLElement | Document): Promise<Offsets> {
const isIntrinsicScrolling = isIntrinsicScrollElement(element)
const scrollContainer = getIntrinsicScrollContainer(element)

const { scrollLeft, scrollTop, scrollHeight, scrollWidth } = scrollContainer
const { height: scrollContainerHeight, width: scrollContainerWidth } = await getElementDimensions(scrollContainer)

const height = isIntrinsicScrolling ? window.innerHeight : scrollContainer.offsetHeight
const width = isIntrinsicScrolling ? window.innerWidth : scrollContainer.offsetWidth
const height = isIntrinsicScrolling ? window.innerHeight : scrollContainerHeight
const width = isIntrinsicScrolling ? window.innerWidth : scrollContainerWidth

const left = Math.round(scrollLeft)
const top = Math.round(scrollTop)
Expand Down
Loading
Loading