From 617ab124a0741fb2023653c3054ff98ecf7dcac8 Mon Sep 17 00:00:00 2001 From: Guido Wagner Date: Tue, 8 Sep 2026 20:22:34 -0300 Subject: [PATCH] perf: derive the ready flag and composite the divider lines useReady tracked readiness in a state map that every measurement wrote to, costing a render pass each; it is now derived from the measurements themselves. The header and footer lines animated their alpha inside box-shadow, repainting both on every frame; they are pseudo elements animating opacity now. --- src/BottomSheet.tsx | 33 +++++++--------- src/hooks/index.tsx | 1 - src/hooks/useReady.tsx | 31 --------------- src/hooks/useSnapPoints.tsx | 78 +++++++++++-------------------------- src/style.css | 35 ++++++++++++++--- 5 files changed, 67 insertions(+), 111 deletions(-) delete mode 100644 src/hooks/useReady.tsx diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index a2b533b0..d5cb28db 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -20,7 +20,6 @@ import { useAriaHider, useFocusTrap, useLayoutEffect, - useReady, useReducedMotion, useScrollLock, useSnapPoints, @@ -87,9 +86,6 @@ export const BottomSheet = React.forwardRef< }, forwardRef ) { - // Before any animations can start we need to measure a few things, like the viewport and the dimensions of content, and header + footer if they exist - const { ready, registerReady } = useReady() - // Controls the drag handler, used by spring operations that happen outside the render loop in React const canDragRef = useRef(false) @@ -123,6 +119,20 @@ export const BottomSheet = React.forwardRef< const prefersReducedMotion = useReducedMotion() + // Nothing can start until the viewport and the content have been measured + const { minSnap, maxSnap, maxHeight, findSnap, ready } = useSnapPoints({ + contentRef, + controlledMaxHeight, + footerEnabled: !!footer, + footerRef, + getSnapPoints, + headerEnabled: header !== false, + headerRef, + heightRef, + lastSnapRef, + resizeSourceRef, + }) + // "Plugins" huhuhu const scrollLockRef = useScrollLock({ targetRef: scrollRef, @@ -140,21 +150,6 @@ export const BottomSheet = React.forwardRef< enabled: ready && blocking && initialFocusRef !== false, }) - const { minSnap, maxSnap, maxHeight, findSnap } = useSnapPoints({ - contentRef, - controlledMaxHeight, - footerEnabled: !!footer, - footerRef, - getSnapPoints, - headerEnabled: header !== false, - headerRef, - heightRef, - lastSnapRef, - ready, - registerReady, - resizeSourceRef, - }) - // Setup refs that are used in cases where full control is needed over when a side effect is executed const maxHeightRef = useRef(maxHeight) const minSnapRef = useRef(minSnap) diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 26af5072..8110ede4 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,7 +1,6 @@ export { useAriaHider } from './useAriaHider' export { useFocusTrap } from './useFocusTrap' export { useLayoutEffect } from './useLayoutEffect' -export { useReady } from './useReady' export { useReducedMotion } from './useReducedMotion' export { useScrollLock } from './useScrollLock' export { useSnapPoints } from './useSnapPoints' diff --git a/src/hooks/useReady.tsx b/src/hooks/useReady.tsx deleted file mode 100644 index 5c7fd454..00000000 --- a/src/hooks/useReady.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Keeps track of wether everything is good to go or not, in the most efficient way possible - -import { useCallback, useEffect, useState } from 'react' - -export function useReady() { - const [ready, setReady] = useState(false) - const [readyMap, updateReadyMap] = useState<{ [key: string]: boolean }>({}) - - const registerReady = useCallback((key: string) => { - // Register the check we're gonna wait for until it's ready - updateReadyMap((ready) => ({ ...ready, [key]: false })) - - return () => { - updateReadyMap((ready) => ({ ...ready, [key]: true })) - } - }, []) - - useEffect(() => { - const states = Object.values(readyMap) - - if (states.length === 0) { - return - } - - if (states.every(Boolean)) { - setReady(true) - } - }, [readyMap]) - - return { ready, registerReady } -} diff --git a/src/hooks/useSnapPoints.tsx b/src/hooks/useSnapPoints.tsx index dd046de3..dad970ef 100644 --- a/src/hooks/useSnapPoints.tsx +++ b/src/hooks/useSnapPoints.tsx @@ -1,11 +1,4 @@ -import React, { - useCallback, - useDebugValue, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import React, { useCallback, useDebugValue, useRef, useState } from 'react' import type { defaultSnapProps, MutableRef, @@ -13,7 +6,6 @@ import type { snapPoints, } from '../types' import { processSnapPoints, roundAndCheckForNaN } from '../utils' -import { useReady } from './useReady' import { useLayoutEffect } from './useLayoutEffect' export function useSnapPoints({ @@ -26,8 +18,6 @@ export function useSnapPoints({ headerRef, heightRef, lastSnapRef, - ready, - registerReady, resizeSourceRef, }: { contentRef: React.RefObject @@ -39,20 +29,18 @@ export function useSnapPoints({ headerRef: React.RefObject heightRef: React.RefObject lastSnapRef: React.RefObject - ready: boolean - registerReady: ReturnType['registerReady'] resizeSourceRef: MutableRef }) { - const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({ - contentRef, - controlledMaxHeight, - footerEnabled, - footerRef, - headerEnabled, - headerRef, - registerReady, - resizeSourceRef, - }) + const { maxHeight, minHeight, headerHeight, footerHeight, ready } = + useDimensions({ + contentRef, + controlledMaxHeight, + footerEnabled, + footerRef, + headerEnabled, + headerRef, + resizeSourceRef, + }) const { snapPoints, minSnap, maxSnap } = processSnapPoints( ready @@ -94,7 +82,7 @@ export function useSnapPoints({ useDebugValue(`minSnap: ${minSnap}, maxSnap:${maxSnap}`) - return { minSnap, maxSnap, findSnap, maxHeight } + return { minSnap, maxSnap, findSnap, maxHeight, ready } } function useDimensions({ @@ -104,7 +92,6 @@ function useDimensions({ footerRef, headerEnabled, headerRef, - registerReady, resizeSourceRef, }: { contentRef: React.RefObject @@ -113,18 +100,9 @@ function useDimensions({ footerRef: React.RefObject headerEnabled: boolean headerRef: React.RefObject - registerReady: ReturnType['registerReady'] resizeSourceRef: MutableRef }) { - const setReady = useMemo( - () => registerReady('contentHeight'), - [registerReady] - ) - const maxHeight = useMaxHeight( - controlledMaxHeight, - registerReady, - resizeSourceRef - ) + const maxHeight = useMaxHeight(controlledMaxHeight, resizeSourceRef) const headerHeight = useElementSizeObserver(headerRef, { label: 'headerHeight', @@ -148,28 +126,26 @@ function useDimensions({ useDebugValue(`minHeight: ${minHeight}`) - const ready = contentHeight > 0 - useEffect(() => { - if (ready) { - setReady() - } - }, [ready, setReady]) - return { maxHeight, minHeight, headerHeight, footerHeight, + // Nothing can be measured or animated until the viewport and the content have a size. + // Deriving this instead of tracking it in state saves a render pass per measurement. + ready: maxHeight > 0 && contentHeight > 0, } } // Respond to changes to padding, happens often on iOS when using env(safe-area-inset-bottom) -// and the user hides or shows the Safari browser toolbar +// And the user hides or shows the Safari browser toolbar const observerOptions: ResizeObserverOptions = { box: 'border-box' } function borderBoxHeight(entry: ResizeObserverEntry): number { const boxSize = entry.borderBoxSize as unknown as - ResizeObserverSize | ResizeObserverSize[] | undefined + | ResizeObserverSize + | ResizeObserverSize[] + | undefined const size = Array.isArray(boxSize) ? boxSize[0] : boxSize if (size && typeof size.blockSize === 'number') { return size.blockSize @@ -179,6 +155,8 @@ function borderBoxHeight(entry: ResizeObserverEntry): number { /** * Hook for determining the size of an element using the Resize Observer API. + * + * @param ref - A React ref to an element */ function useElementSizeObserver( ref: React.RefObject, @@ -224,27 +202,18 @@ function useElementSizeObserver( // Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones function useMaxHeight( controlledMaxHeight: number | undefined, - registerReady: ReturnType['registerReady'], resizeSourceRef: MutableRef ) { - const setReady = useMemo(() => registerReady('maxHeight'), [registerReady]) const [maxHeight, setMaxHeight] = useState(() => { if (controlledMaxHeight) { return roundAndCheckForNaN(controlledMaxHeight) } return typeof window !== 'undefined' ? window.innerHeight : 0 }) - const ready = maxHeight > 0 const raf = useRef(0) useDebugValue(controlledMaxHeight ? 'controlled' : 'auto') - useEffect(() => { - if (ready) { - setReady() - } - }, [ready, setReady]) - useLayoutEffect(() => { // Bail if the max height is a controlled prop if (controlledMaxHeight) { @@ -271,13 +240,12 @@ function useMaxHeight( window.addEventListener('resize', handleResize) setMaxHeight(window.innerHeight) resizeSourceRef.current = 'window' - setReady() return () => { window.removeEventListener('resize', handleResize) cancelAnimationFrame(raf.current) } - }, [controlledMaxHeight, setReady, resizeSourceRef]) + }, [controlledMaxHeight, resizeSourceRef]) return maxHeight } diff --git a/src/style.css b/src/style.css index 95982fbc..88855fdf 100644 --- a/src/style.css +++ b/src/style.css @@ -64,14 +64,25 @@ padding: 16px; } [data-rsbs-header] { + position: relative; text-align: center; user-select: none; - box-shadow: 0 1px 0 - rgba(46, 59, 66, calc(var(--rsbs-content-opacity) * 0.125)); z-index: 1; padding-top: calc(20px + env(safe-area-inset-top)); padding-bottom: 8px; } +[data-rsbs-header]:after { + position: absolute; + content: ''; + left: 0; + right: 0; + bottom: 0; + height: 1px; + pointer-events: none; + background-color: rgba(46, 59, 66, 0.125); + opacity: var(--rsbs-content-opacity); + will-change: opacity; +} [data-rsbs-header]:before { position: absolute; content: ''; @@ -90,9 +101,11 @@ } } [data-rsbs-has-header='false'] [data-rsbs-header] { - box-shadow: none; padding-top: calc(12px + env(safe-area-inset-top)); } +[data-rsbs-has-header='false'] [data-rsbs-header]:after { + display: none; +} [data-rsbs-scroll] { flex-shrink: 1; flex-grow: 1; @@ -116,12 +129,24 @@ overflow: hidden; } [data-rsbs-footer] { - box-shadow: 0 -1px 0 rgba(46, 59, 66, calc(var(--rsbs-content-opacity) * 0.125)), - 0 2px 0 var(--rsbs-bg); + position: relative; + box-shadow: 0 2px 0 var(--rsbs-bg); overflow: hidden; z-index: 1; padding-bottom: calc(16px + env(safe-area-inset-bottom)); } +[data-rsbs-footer]:before { + position: absolute; + content: ''; + left: 0; + right: 0; + top: 0; + height: 1px; + pointer-events: none; + background-color: rgba(46, 59, 66, 0.125); + opacity: var(--rsbs-content-opacity); + will-change: opacity; +} [data-rsbs-is-dismissable='true'], [data-rsbs-is-dismissable='false']:is([data-rsbs-state='opening'], [data-rsbs-state='closing']) {