From d4fc6855c3a609545ee2b0d1e297208bb118e01e Mon Sep 17 00:00:00 2001 From: "e.mukhametkhanov" Date: Mon, 23 Mar 2026 15:08:14 +0300 Subject: [PATCH 01/14] feat(useResizeObserver): refactor and optimize hook --- .../docs/common/hooks/useInfiniteList.tsx | 7 +- .../components/ComponentOverviewCard.tsx | 7 +- .../components/CarouselBase/CarouselBase.tsx | 15 +- .../components/FixedLayout/FixedLayout.tsx | 15 +- .../vkui/src/components/Skeleton/Skeleton.tsx | 9 +- .../vkui/src/components/Textarea/Textarea.tsx | 9 +- .../vkui/src/hooks/useResizeObserver.test.tsx | 95 --------- packages/vkui/src/hooks/useResizeObserver.ts | 54 ------ .../useResizeObserver/useResizeObserver.ts | 144 ++++++++++++++ .../useWindowResizeObserver.ts | 182 ++++++++++++++++++ .../vkui/src/hooks/useVirtualKeyboardState.ts | 72 +++---- 11 files changed, 406 insertions(+), 203 deletions(-) delete mode 100644 packages/vkui/src/hooks/useResizeObserver.test.tsx delete mode 100644 packages/vkui/src/hooks/useResizeObserver.ts create mode 100644 packages/vkui/src/hooks/useResizeObserver/useResizeObserver.ts create mode 100644 packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts diff --git a/packages/vkui/docs/common/hooks/useInfiniteList.tsx b/packages/vkui/docs/common/hooks/useInfiniteList.tsx index e939af1156c..da11e57eedf 100644 --- a/packages/vkui/docs/common/hooks/useInfiniteList.tsx +++ b/packages/vkui/docs/common/hooks/useInfiniteList.tsx @@ -1,7 +1,7 @@ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { Spinner } from '../../../src'; -import { useResizeObserver } from '../../../src/hooks/useResizeObserver'; import { useDOM } from '../../../src/lib/dom'; +import { useResizeObserver } from '../../../src/hooks/useResizeObserver/useResizeObserver.ts'; const SPINNER_HEIGHT = 24; const WINDOW_PADDING_BOTTOM = 64; @@ -138,7 +138,10 @@ export const useInfiniteList =
( useEffect(recalculateSectionsBounds, [sectionsRefs]); - useResizeObserver(containerRef, () => requestAnimationFrame(showMoreVisible)); + useResizeObserver({ + ref: containerRef, + onResize: showMoreVisible, + }); useEffect(() => { window!.addEventListener('scroll', recalculateVisibleSections); diff --git a/packages/vkui/docs/components-overview/components/ComponentOverviewCard.tsx b/packages/vkui/docs/components-overview/components/ComponentOverviewCard.tsx index dbc9fee268e..2ec3dc666e2 100644 --- a/packages/vkui/docs/components-overview/components/ComponentOverviewCard.tsx +++ b/packages/vkui/docs/components-overview/components/ComponentOverviewCard.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { Card, Mark, Title, useDirection } from '../../../src'; -import { useResizeObserver } from '../../../src/hooks/useResizeObserver'; import { useDOM } from '../../../src/lib/dom'; import type { CSSCustomProperties } from '../../../src/types'; import { useOverviewLayoutContext } from '../../common/components/OverviewLayoutContext'; import type { ComponentConfigData } from '../config'; import styles from './ComponentOverviewCard.module.css'; +import { useResizeObserver } from '../../../src/hooks/useResizeObserver/useResizeObserver'; const CONTENT_PADDING = 10; @@ -74,7 +74,10 @@ export const ComponentOverviewCard: React.FC = ({ React.useEffect(() => calculateScale(), [calculateScale]); - useResizeObserver(containerRef, calculateScale); + useResizeObserver({ + ref: containerRef, + onResize: calculateScale, + }); const componentUrl = React.useMemo(() => { if (!window) { diff --git a/packages/vkui/src/components/CarouselBase/CarouselBase.tsx b/packages/vkui/src/components/CarouselBase/CarouselBase.tsx index e32eaad2cd8..e80e84b2cd9 100644 --- a/packages/vkui/src/components/CarouselBase/CarouselBase.tsx +++ b/packages/vkui/src/components/CarouselBase/CarouselBase.tsx @@ -5,8 +5,8 @@ import { classNames } from '@vkontakte/vkjs'; import { useConfigDirection } from '../../hooks/useConfigDirection'; import { useExternRef } from '../../hooks/useExternRef'; import { useMutationObserver } from '../../hooks/useMutationObserver'; -import { useResizeObserver } from '../../hooks/useResizeObserver'; -import { useDOM } from '../../lib/dom'; +import { useResizeObserver } from '../../hooks/useResizeObserver/useResizeObserver'; +import { useWindowResizeObserver } from '../../hooks/useResizeObserver/useWindowResizeObserver'; import { mergeCalls } from '../../lib/mergeCalls'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import { warnOnce } from '../../lib/warnOnce'; @@ -347,8 +347,15 @@ export const CarouselBase = ({ initializeSlides(); } }; - const { window } = useDOM(); - useResizeObserver(resizeSource === 'element' ? rootRef : window, onResize); + useWindowResizeObserver({ + enabled: resizeSource === 'window', + onResize, + }); + useResizeObserver({ + ref: rootRef, + enabled: resizeSource === 'element', + onResize, + }); const loopedSlideChangePerform = () => { const { snaps, slides } = slidesManager.current; diff --git a/packages/vkui/src/components/FixedLayout/FixedLayout.tsx b/packages/vkui/src/components/FixedLayout/FixedLayout.tsx index 680943c33ec..b669e49c8fb 100644 --- a/packages/vkui/src/components/FixedLayout/FixedLayout.tsx +++ b/packages/vkui/src/components/FixedLayout/FixedLayout.tsx @@ -4,8 +4,8 @@ import { useCallback } from 'react'; import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { usePlatform } from '../../hooks/usePlatform'; -import { useResizeObserver } from '../../hooks/useResizeObserver'; -import { useDOM } from '../../lib/dom'; +import { useResizeObserver } from '../../hooks/useResizeObserver/useResizeObserver.ts'; +import { useWindowResizeObserver } from '../../hooks/useResizeObserver/useWindowResizeObserver'; import { setRef } from '../../lib/utils'; import { warnOnce } from '../../lib/warnOnce'; import type { HasComponent, HTMLAttributesWithRootRef } from '../../types'; @@ -62,7 +62,6 @@ export const FixedLayout = ({ const platform = usePlatform(); const ref = React.useRef(null); const [width, setWidth] = React.useState(undefined); - const { window } = useDOM(); const { colRef } = React.useContext(SplitColContext); const parentRef = React.useRef(null); @@ -99,8 +98,14 @@ export const FixedLayout = ({ }; React.useEffect(doResize, [colRef, platform, ref, useParentWidth]); - useResizeObserver(window, doResize); - useResizeObserver(useParentWidth ? parentRef : colRef, doResize); + useWindowResizeObserver({ + initialEmit: false, + onResize: doResize, + }); + useResizeObserver({ + ref: useParentWidth ? parentRef : colRef, + onResize: doResize, + }); return ( ) { - const { document, window } = useDOM(); + const { document } = useDOM(); const [[skeletonGradientLeft, prevSkeletonGradientLeft], setSkeletonGradientLeft] = useStateWithPrev('0'); @@ -86,7 +86,10 @@ function useSkeletonPosition(rootRef: React.RefObject) { }, [document, prevSkeletonGradientLeft, rootRef, setSkeletonGradientLeft]); React.useEffect(updatePosition, [updatePosition]); - useResizeObserver(window, updatePosition); + useWindowResizeObserver({ + initialEmit: false, + onResize: updatePosition, + }); return skeletonGradientLeft; } diff --git a/packages/vkui/src/components/Textarea/Textarea.tsx b/packages/vkui/src/components/Textarea/Textarea.tsx index 2025747c1ef..143c2b4cce0 100644 --- a/packages/vkui/src/components/Textarea/Textarea.tsx +++ b/packages/vkui/src/components/Textarea/Textarea.tsx @@ -6,9 +6,8 @@ import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useExternRef } from '../../hooks/useExternRef'; import { useMergeProps } from '../../hooks/useMergeProps'; import { usePlatform } from '../../hooks/usePlatform'; -import { useResizeObserver } from '../../hooks/useResizeObserver'; +import { useWindowResizeObserver } from '../../hooks/useResizeObserver/useWindowResizeObserver'; import { callMultiple } from '../../lib/callMultiple'; -import { useDOM } from '../../lib/dom'; import { warnOnce } from '../../lib/warnOnce'; import type { HasAlign, HasDataAttribute, HasRootRef } from '../../types'; import { FormField, type FormFieldProps } from '../FormField/FormField'; @@ -135,7 +134,6 @@ export const Textarea = ({ const { density = 'none' } = useAdaptivity(); const platform = usePlatform(); - const { window } = useDOM(); const { className, ...rootProps } = useMergeProps(restProps, slotProps?.root); @@ -181,7 +179,10 @@ export const Textarea = ({ const elementRef = useExternRef(getTextAreaRef, refResizeTextarea); React.useEffect(resize, [resize, density, platform, value]); - useResizeObserver(window, resize); + useWindowResizeObserver({ + initialEmit: false, + onResize: resize, + }); return ( { - const callbacks = new Set(); - - class MockResizeObserver implements ResizeObserver { - constructor(callback: ResizeObserverCallback) { - callbacks.add(callback); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - observe() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - unobserve() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - disconnect() {} - } - - const originalResizeObserver = window.ResizeObserver; - window.ResizeObserver = MockResizeObserver; - - return { - triggerResize: () => { - callbacks.forEach((callback) => { - callback([], {} as unknown as ResizeObserver); - }); - }, - restore: () => { - window.ResizeObserver = originalResizeObserver; - }, - }; -}; - -describe('useResizeObserver', () => { - const Fixture = ({ - mockedBlocksIds, - resizeCallback, - useWindow = false, - }: { - mockedBlocksIds: string[]; - resizeCallback: () => void; - useWindow?: boolean; - }) => { - const ref = useRef(null); - useResizeObserver(useWindow ? window : ref, resizeCallback); - return ( -
- {mockedBlocksIds.map((id) => ( -
- ))} -
- ); - }; - - it('should call callback when add block', async () => { - const callback = vi.fn(); - - const result = render(); - - await act(async () => { - result.rerender( - , - ); - }); - - expect(callback).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('block-2')).toBeInTheDocument(); - }); - - it('should use ResizeObserver when available', () => { - const callback = vi.fn(); - const { triggerResize, restore } = mockResizeObserver(); - - render(); - - triggerResize(); - - expect(callback).toHaveBeenCalledTimes(1); - restore(); - }); - - it('should handle window resize events', () => { - const callback = vi.fn(); - - render(); - - act(() => { - window.dispatchEvent(new Event('resize')); - }); - - expect(callback).toHaveBeenCalledExactlyOnceWith(window); - }); -}); diff --git a/packages/vkui/src/hooks/useResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver.ts deleted file mode 100644 index e8fc57a9362..00000000000 --- a/packages/vkui/src/hooks/useResizeObserver.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect } from 'react'; -import type * as React from 'react'; -import { useDOM } from '../lib/dom'; -import { CustomResizeObserver } from '../lib/floating/customResizeObserver'; -import { isRefObject } from '../lib/isRefObject'; -import { useStableCallback } from './useStableCallback'; - -/** - * Хук вызывает переданный коллбэк при изменении размеров элемента. - */ -export function useResizeObserver( - ref: React.RefObject | Window | null | undefined, - callback: (element: HTMLElement | Window) => void, -): void { - const stableCallback = useStableCallback(callback); - const { window } = useDOM(); - - useEffect( - function addResizeObserverHandler() { - if (!ref || !window) { - return; - } - - if (ref === window) { - const onResize = () => stableCallback(ref); - ref.addEventListener('resize', onResize); - return () => ref.removeEventListener('resize', onResize); - } - - /* istanbul ignore if: невозможный кейс (в SSR вызова этой функции не будет) */ - if (!isRefObject(ref) || !ref.current) { - return; - } - const element = ref.current; - const canUseResizeObserver = - 'ResizeObserver' in window && window.ResizeObserver !== undefined; - - const observeFn = () => stableCallback(element); - - const observer: ResizeObserver | CustomResizeObserver = canUseResizeObserver - ? // eslint-disable-next-line compat/compat - new ResizeObserver(observeFn) - : new CustomResizeObserver(observeFn); - observer.observe(element); - - if (observer instanceof CustomResizeObserver) { - observer.appendToTheDOM(); - } - - return () => observer.disconnect(); - }, - [ref, stableCallback, window], - ); -} diff --git a/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.ts new file mode 100644 index 00000000000..b2c51983abb --- /dev/null +++ b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.ts @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { useExternRef } from '../useExternRef'; +import { useStableCallback } from '../useStableCallback'; + +export type ResizePayload = { + target: T; + width: number; + height: number; + entry?: ResizeObserverEntry; +}; + +type ElementResizeOptions = { + ref?: React.Ref; + enabled?: boolean; + box?: ResizeObserverBoxOptions; + rafBatch?: boolean; + onResize: (payload: ResizePayload) => void; +}; + +type ResizeHandler = (entry: ResizeObserverEntry) => void; + +type ResizePool = { + observer: ResizeObserver; + handlers: WeakMap; +}; + +const resizePools = new Map(); + +function getResizePool(box: ResizeObserverBoxOptions = 'content-box'): ResizePool { + const key = `box:${box}`; + const existing = resizePools.get(key); + if (existing) { return existing; } + + const handlers = new WeakMap(); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + handlers.get(entry.target)?.(entry); + } + }); + + const pool: ResizePool = { observer, handlers }; + resizePools.set(key, pool); + return pool; +} + +function getEntrySize(entry: ResizeObserverEntry) { + const borderBoxSize = entry.borderBoxSize; + + if (borderBoxSize?.length) { + return { + width: borderBoxSize[0].inlineSize, + height: borderBoxSize[0].blockSize, + }; + } + + const contentBoxSize = entry.contentBoxSize; + + if (contentBoxSize?.length) { + return { + width: contentBoxSize[0].inlineSize, + height: contentBoxSize[0].blockSize, + }; + } + + return { + width: entry.contentRect.width, + height: entry.contentRect.height, + }; +} + +export function useResizeObserver( + options: ElementResizeOptions, +): React.RefCallback { + const { + ref: externalRef, + enabled = true, + box = 'content-box', + rafBatch = true, + onResize: onResizeProp, + } = options; + + const [node, setNode] = React.useState(null); + const mergedRef = useExternRef(externalRef); + + const onResize = useStableCallback<[ResizePayload], void>(onResizeProp); + const rafIdRef = React.useRef(null); + const latestEntryRef = React.useRef(null); + + React.useEffect(() => { + if (!node || !enabled) { return; } + + const pool = getResizePool(box); + + const emit = (entry: ResizeObserverEntry) => { + const { width, height } = getEntrySize(entry); + onResize({ + target: node, + width, + height, + entry, + }); + }; + + const scheduleEmit = (entry: ResizeObserverEntry) => { + if (!rafBatch) { + emit(entry); + return; + } + + latestEntryRef.current = entry; + + if (rafIdRef.current !== null) { return; } + + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + const latest = latestEntryRef.current; + if (latest) { emit(latest); } + }); + }; + + pool.handlers.set(node, scheduleEmit); + pool.observer.observe(node, { box }); + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + + latestEntryRef.current = null; + pool.handlers.delete(node); + pool.observer.unobserve(node); + }; + }, [node, enabled, box, rafBatch, onResize]); + + return React.useCallback>( + (el: T | null) => { + mergedRef.current = el; + setNode(el); + }, + [mergedRef], + ); +} diff --git a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts new file mode 100644 index 00000000000..8b1164ab8ac --- /dev/null +++ b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts @@ -0,0 +1,182 @@ +import * as React from 'react'; +import { useStableCallback } from '../useStableCallback'; + +export type WindowResizePayload = { + target: Window; + width: number; + height: number; +}; + +export type WindowResizeOptions = { + enabled?: boolean; + rafBatch?: boolean; + useVisualViewport?: boolean; + initialEmit?: boolean; + onResize: (payload: WindowResizePayload) => void; +}; + +type WindowSubscriber = { + onResize: WindowResizeOptions['onResize']; + rafBatch: boolean; + useVisualViewport: boolean; + rafIdRef: React.RefObject; + pendingRef: React.RefObject<{ width: number; height: number } | null>; +}; + +const windowSubscribers = new Set(); +let windowListenerAttached = false; +let visualViewportListenerAttached = false; + +function getWindowSize(useVisualViewport: boolean) { + const visualViewport = useVisualViewport ? globalThis.window.visualViewport : null; + + if (visualViewport !== null) { + return { + width: visualViewport.width, + height: visualViewport.height, + }; + } + + return { + width: globalThis.window.innerWidth, + height: globalThis.window.innerHeight, + }; +} + +function notifySubscribers(subscriberPredicate: (subscriber: WindowSubscriber) => boolean) { + for (const sub of windowSubscribers) { + if (!subscriberPredicate(sub)) { + continue; + } + + const size = getWindowSize(sub.useVisualViewport); + const emit = () => { + sub.onResize({ + target: globalThis.window, + width: size.width, + height: size.height, + }); + }; + + if (!sub.rafBatch) { + emit(); + continue; + } + + sub.pendingRef.current = size; + + if (sub.rafIdRef.current !== null) { continue; } + + sub.rafIdRef.current = requestAnimationFrame(() => { + sub.rafIdRef.current = null; + const pending = sub.pendingRef.current; + if (!pending) { return; } + + sub.pendingRef.current = null; + sub.onResize({ + target: globalThis.window, + width: pending.width, + height: pending.height, + }); + }); + } +} + +function notifyWindowSubscribers() { + notifySubscribers((sub) => !sub.useVisualViewport); +} + +function notifyVisualViewportSubscribers() { + notifySubscribers((sub) => sub.useVisualViewport); +} + +function ensureWindowListener() { + if (windowListenerAttached) { return; } + if (![...windowSubscribers].some((sub) => !sub.useVisualViewport)) { return; } + + globalThis.window.addEventListener('resize', notifyWindowSubscribers, { passive: true }); + windowListenerAttached = true; +} + +function ensureVisualViewportListener() { + if (visualViewportListenerAttached) { return; } + if (![...windowSubscribers].some((sub) => sub.useVisualViewport)) { return; } + + globalThis.window.visualViewport?.addEventListener('resize', notifyVisualViewportSubscribers, { + passive: true, + }); + visualViewportListenerAttached = true; +} + +function maybeDetachWindowListener() { + if (!windowListenerAttached) { return; } + if ([...windowSubscribers].some((sub) => !sub.useVisualViewport)) { return; } + + globalThis.window.removeEventListener('resize', notifyWindowSubscribers); + windowListenerAttached = false; +} + +function maybeDetachVisualViewportListener() { + if (!visualViewportListenerAttached) { return; } + if ([...windowSubscribers].some((sub) => sub.useVisualViewport)) { return; } + + globalThis.window.visualViewport?.removeEventListener( + 'resize', + notifyVisualViewportSubscribers, + ); + visualViewportListenerAttached = false; +} + +export function useWindowResizeObserver(options: WindowResizeOptions) { + const { + enabled = true, + rafBatch = true, + useVisualViewport = false, + initialEmit = true, + onResize: onResizeProp, + } = options; + + const onResize = useStableCallback<[WindowResizePayload], void>(onResizeProp); + const rafIdRef = React.useRef(null); + const pendingRef = React.useRef<{ width: number; height: number } | null>(null); + + React.useEffect(() => { + if (!enabled) { return; } + + const sub: WindowSubscriber = { + onResize, + rafBatch, + useVisualViewport, + rafIdRef, + pendingRef, + }; + + windowSubscribers.add(sub); + if (useVisualViewport) { + ensureVisualViewportListener(); + } else { + ensureWindowListener(); + } + + if (initialEmit) { + const size = getWindowSize(useVisualViewport); + onResize({ + target: globalThis.window, + width: size.width, + height: size.height, + }); + } + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + + pendingRef.current = null; + windowSubscribers.delete(sub); + maybeDetachVisualViewportListener(); + maybeDetachWindowListener(); + }; + }, [enabled, rafBatch, useVisualViewport, initialEmit, onResize]); +} diff --git a/packages/vkui/src/hooks/useVirtualKeyboardState.ts b/packages/vkui/src/hooks/useVirtualKeyboardState.ts index a34bded711d..b25a934aec4 100644 --- a/packages/vkui/src/hooks/useVirtualKeyboardState.ts +++ b/packages/vkui/src/hooks/useVirtualKeyboardState.ts @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from 'react'; -import { debounce, throttle } from '@vkontakte/vkjs'; +import { debounce, noop, throttle } from '@vkontakte/vkjs'; import { getVisualViewport, isHTMLContentEditableElement, useDOM, type VisualViewport, } from '../lib/dom'; +import { useWindowResizeObserver } from './useResizeObserver/useWindowResizeObserver'; export type VirtualKeyboardState = { opened: boolean }; @@ -88,44 +89,47 @@ export function useVirtualKeyboardState(enabled = true): VirtualKeyboardState { [enabled, window, document], ); - useEffect( - function handleVirtualKeyboardOpened() { - if (!focusedEl || !window) { - return; - } + const handleResizeRef = useRef<() => void>(noop); - const handleResize = debounce(() => { - /* istanbul ignore if: нереалистичный кейс, проверяем в угоду TypeScript */ - if (prevVisualViewportRef.current === null) { - return; - } + useEffect(() => { + if (!window) { + handleResizeRef.current = noop; + return; + } - const nextVisualViewport = getVisualViewport(window); + const handleResize = debounce(() => { + /* istanbul ignore if: нереалистичный кейс, проверяем в угоду TypeScript */ + if (prevVisualViewportRef.current === null) { + return; + } - const { offsetTop: prevOffsetTop, height: prevHeight } = prevVisualViewportRef.current; - const { offsetTop: nextOffsetTop, height: nextHeight } = nextVisualViewport; - if (prevOffsetTop !== nextOffsetTop || prevHeight !== nextHeight) { - setKeyboardOpened(true); - prevVisualViewportRef.current = nextVisualViewport; - } - }, 100); + const nextVisualViewport = getVisualViewport(window); - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleResize); - } else { - window.addEventListener('resize', handleResize); + const { offsetTop: prevOffsetTop, height: prevHeight } = prevVisualViewportRef.current; + const { offsetTop: nextOffsetTop, height: nextHeight } = nextVisualViewport; + if (prevOffsetTop !== nextOffsetTop || prevHeight !== nextHeight) { + setKeyboardOpened(true); + prevVisualViewportRef.current = nextVisualViewport; } - - return function dispose() { - if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', handleResize); - } else { - window.removeEventListener('resize', handleResize); - } - }; - }, - [focusedEl, window], - ); + }, 100); + + handleResizeRef.current = handleResize; + return () => { + handleResizeRef.current = noop; + }; + }, [window]); + + useWindowResizeObserver({ + enabled: !!focusedEl && !!window?.visualViewport, + useVisualViewport: true, + onResize: () => handleResizeRef.current(), + }); + + useWindowResizeObserver({ + enabled: !!focusedEl && !window?.visualViewport, + useVisualViewport: false, + onResize: () => handleResizeRef.current(), + }); useEffect( function preventWindowScrollIfKeyboardOpened() { From 3f31e63d5dc155e66b224e59e324ee79cd4052d2 Mon Sep 17 00:00:00 2001 From: "e.mukhametkhanov" Date: Mon, 23 Mar 2026 18:38:25 +0300 Subject: [PATCH 02/14] feat(useResizeObserver): improve logic --- .../vkui/src/components/Textarea/Textarea.tsx | 12 +++-- .../useResizeObserver/useResizeObserver.ts | 37 ++++++++------ .../useWindowResizeObserver.ts | 49 +++++++++++++------ 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/packages/vkui/src/components/Textarea/Textarea.tsx b/packages/vkui/src/components/Textarea/Textarea.tsx index 143c2b4cce0..c7fc56aa969 100644 --- a/packages/vkui/src/components/Textarea/Textarea.tsx +++ b/packages/vkui/src/components/Textarea/Textarea.tsx @@ -6,7 +6,7 @@ import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useExternRef } from '../../hooks/useExternRef'; import { useMergeProps } from '../../hooks/useMergeProps'; import { usePlatform } from '../../hooks/usePlatform'; -import { useWindowResizeObserver } from '../../hooks/useResizeObserver/useWindowResizeObserver'; +import { useResizeObserver } from '../../hooks/useResizeObserver/useResizeObserver'; import { callMultiple } from '../../lib/callMultiple'; import { warnOnce } from '../../lib/warnOnce'; import type { HasAlign, HasDataAttribute, HasRootRef } from '../../types'; @@ -135,7 +135,8 @@ export const Textarea = ({ const { density = 'none' } = useAdaptivity(); const platform = usePlatform(); - const { className, ...rootProps } = useMergeProps(restProps, slotProps?.root); + const { className, getRootRef, ...rootProps } = useMergeProps(restProps, slotProps?.root); + const rootRef = useExternRef(getRootRef); const { onChange, @@ -179,8 +180,10 @@ export const Textarea = ({ const elementRef = useExternRef(getTextAreaRef, refResizeTextarea); React.useEffect(resize, [resize, density, platform, value]); - useWindowResizeObserver({ - initialEmit: false, + + useResizeObserver({ + enabled: grow, + ref: rootRef, onResize: resize, }); @@ -201,6 +204,7 @@ export const Textarea = ({ afterAlign={afterAlign} beforeAlign={beforeAlign} maxHeight={maxHeight} + getRootRef={rootRef} {...rootProps} > = { @@ -10,7 +9,7 @@ export type ResizePayload = { }; type ElementResizeOptions = { - ref?: React.Ref; + ref?: React.RefObject; enabled?: boolean; box?: ResizeObserverBoxOptions; rafBatch?: boolean; @@ -29,7 +28,9 @@ const resizePools = new Map(); function getResizePool(box: ResizeObserverBoxOptions = 'content-box'): ResizePool { const key = `box:${box}`; const existing = resizePools.get(key); - if (existing) { return existing; } + if (existing) { + return existing; + } const handlers = new WeakMap(); @@ -81,14 +82,22 @@ export function useResizeObserver( } = options; const [node, setNode] = React.useState(null); - const mergedRef = useExternRef(externalRef); const onResize = useStableCallback<[ResizePayload], void>(onResizeProp); const rafIdRef = React.useRef(null); const latestEntryRef = React.useRef(null); React.useEffect(() => { - if (!node || !enabled) { return; } + const nextNode = externalRef?.current; + if (nextNode && nextNode !== node) { + setNode(nextNode); + } + }, [externalRef, node]); + + React.useEffect(() => { + if (!node || !enabled) { + return; + } const pool = getResizePool(box); @@ -110,12 +119,16 @@ export function useResizeObserver( latestEntryRef.current = entry; - if (rafIdRef.current !== null) { return; } + if (rafIdRef.current !== null) { + return; + } rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null; const latest = latestEntryRef.current; - if (latest) { emit(latest); } + if (latest) { + emit(latest); + } }); }; @@ -134,11 +147,7 @@ export function useResizeObserver( }; }, [node, enabled, box, rafBatch, onResize]); - return React.useCallback>( - (el: T | null) => { - mergedRef.current = el; - setNode(el); - }, - [mergedRef], - ); + return React.useCallback>((el: T | null) => { + setNode(el); + }, []); } diff --git a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts index 8b1164ab8ac..157666d87eb 100644 --- a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts +++ b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts @@ -65,12 +65,16 @@ function notifySubscribers(subscriberPredicate: (subscriber: WindowSubscriber) = sub.pendingRef.current = size; - if (sub.rafIdRef.current !== null) { continue; } + if (sub.rafIdRef.current !== null) { + continue; + } sub.rafIdRef.current = requestAnimationFrame(() => { sub.rafIdRef.current = null; const pending = sub.pendingRef.current; - if (!pending) { return; } + if (!pending) { + return; + } sub.pendingRef.current = null; sub.onResize({ @@ -91,16 +95,24 @@ function notifyVisualViewportSubscribers() { } function ensureWindowListener() { - if (windowListenerAttached) { return; } - if (![...windowSubscribers].some((sub) => !sub.useVisualViewport)) { return; } + if (windowListenerAttached) { + return; + } + if (![...windowSubscribers].some((sub) => !sub.useVisualViewport)) { + return; + } globalThis.window.addEventListener('resize', notifyWindowSubscribers, { passive: true }); windowListenerAttached = true; } function ensureVisualViewportListener() { - if (visualViewportListenerAttached) { return; } - if (![...windowSubscribers].some((sub) => sub.useVisualViewport)) { return; } + if (visualViewportListenerAttached) { + return; + } + if (![...windowSubscribers].some((sub) => sub.useVisualViewport)) { + return; + } globalThis.window.visualViewport?.addEventListener('resize', notifyVisualViewportSubscribers, { passive: true, @@ -109,21 +121,26 @@ function ensureVisualViewportListener() { } function maybeDetachWindowListener() { - if (!windowListenerAttached) { return; } - if ([...windowSubscribers].some((sub) => !sub.useVisualViewport)) { return; } + if (!windowListenerAttached) { + return; + } + if ([...windowSubscribers].some((sub) => !sub.useVisualViewport)) { + return; + } globalThis.window.removeEventListener('resize', notifyWindowSubscribers); windowListenerAttached = false; } function maybeDetachVisualViewportListener() { - if (!visualViewportListenerAttached) { return; } - if ([...windowSubscribers].some((sub) => sub.useVisualViewport)) { return; } + if (!visualViewportListenerAttached) { + return; + } + if ([...windowSubscribers].some((sub) => sub.useVisualViewport)) { + return; + } - globalThis.window.visualViewport?.removeEventListener( - 'resize', - notifyVisualViewportSubscribers, - ); + globalThis.window.visualViewport?.removeEventListener('resize', notifyVisualViewportSubscribers); visualViewportListenerAttached = false; } @@ -141,7 +158,9 @@ export function useWindowResizeObserver(options: WindowResizeOptions) { const pendingRef = React.useRef<{ width: number; height: number } | null>(null); React.useEffect(() => { - if (!enabled) { return; } + if (!enabled) { + return; + } const sub: WindowSubscriber = { onResize, From 00ee4c7db30bb22b50d29f8fb012f878a6b87857 Mon Sep 17 00:00:00 2001 From: "e.mukhametkhanov" Date: Thu, 26 Mar 2026 12:10:48 +0300 Subject: [PATCH 03/14] feat: add tests --- .../useResizeObserver.test.tsx | 312 ++++++++++++++++++ .../useWindowResizeObserver.test.tsx | 250 ++++++++++++++ 2 files changed, 562 insertions(+) create mode 100644 packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx create mode 100644 packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx diff --git a/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx new file mode 100644 index 00000000000..39831350a21 --- /dev/null +++ b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx @@ -0,0 +1,312 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +type MockResizeObserverEntry = Partial & { + target: Element; + contentRect: DOMRectReadOnly; +}; + +class ResizeObserverMock { + public static instances: ResizeObserverMock[] = []; + + public observe = vi.fn(); + public unobserve = vi.fn(); + public disconnect = vi.fn(); + + private readonly callback: ResizeObserverCallback; + + public constructor(callback: ResizeObserverCallback) { + this.callback = callback; + ResizeObserverMock.instances.push(this); + } + + public emit(entries: MockResizeObserverEntry[]) { + this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver); + } + + public static reset() { + ResizeObserverMock.instances = []; + } +} + +function getObserverForTarget(target: Element): ResizeObserverMock { + const observer = ResizeObserverMock.instances.find((instance) => + instance.observe.mock.calls.some(([observedTarget]) => observedTarget === target), + ); + if (!observer) { + throw new Error('ResizeObserver for target was not found'); + } + return observer; +} + +function createEntry( + target: Element, + { + width = 100, + height = 50, + borderBoxSize, + contentBoxSize, + }: { + width?: number; + height?: number; + borderBoxSize?: { inlineSize: number; blockSize: number }[]; + contentBoxSize?: { inlineSize: number; blockSize: number }[]; + } = {}, +): MockResizeObserverEntry { + return { + target, + contentRect: { width, height } as DOMRectReadOnly, + borderBoxSize: borderBoxSize as ResizeObserverSize[], + contentBoxSize: contentBoxSize as ResizeObserverSize[], + }; +} + +describe('useResizeObserver', () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + + beforeEach(() => { + vi.resetModules(); + ResizeObserverMock.reset(); + globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + vi.restoreAllMocks(); + globalThis.ResizeObserver = originalResizeObserver; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it('observes node and emits resize payload immediately when rafBatch=false', async () => { + const onResize = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = () => { + const ref = useResizeObserver({ rafBatch: false, onResize }); + return
; + }; + + const { getByTestId } = render(); + const target = getByTestId('target'); + const observer = getObserverForTarget(target); + + expect(observer.observe).toHaveBeenCalledWith(target, { box: 'content-box' }); + + observer.emit([createEntry(target, { width: 320, height: 180 })]); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith({ + target, + width: 320, + height: 180, + entry: expect.objectContaining({ target }), + }); + }); + + it('uses latest entry in one RAF tick when batching is enabled', async () => { + const onResize = vi.fn(); + const rafCallbacks = new Map(); + let rafId = 0; + const { useResizeObserver } = await import('./useResizeObserver'); + + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + rafId += 1; + rafCallbacks.set(rafId, cb); + return rafId; + }); + + globalThis.cancelAnimationFrame = vi.fn(); + + const Fixture = () => { + const ref = useResizeObserver({ onResize }); + return
; + }; + + const { getByTestId } = render(); + const target = getByTestId('target'); + const observer = getObserverForTarget(target); + + observer.emit([createEntry(target, { width: 100, height: 40 })]); + observer.emit([createEntry(target, { width: 200, height: 90 })]); + + expect(onResize).not.toHaveBeenCalled(); + expect(globalThis.requestAnimationFrame).toHaveBeenCalledTimes(1); + + await act(async () => { + rafCallbacks.get(1)?.(0); + }); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenLastCalledWith({ + target, + width: 200, + height: 90, + entry: expect.objectContaining({ target }), + }); + }); + + it('cancels pending RAF and unobserves node on unmount', async () => { + const onResize = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + globalThis.requestAnimationFrame = vi.fn(() => 42); + globalThis.cancelAnimationFrame = vi.fn(); + + const Fixture = () => { + const ref = useResizeObserver({ onResize }); + return
; + }; + + const { getByTestId, unmount } = render(); + const target = getByTestId('target'); + const observer = getObserverForTarget(target); + + observer.emit([createEntry(target)]); + unmount(); + + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(42); + expect(observer.unobserve).toHaveBeenCalledWith(target); + }); + + it('does not subscribe when disabled', async () => { + const onResize = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = () => { + const ref = useResizeObserver({ enabled: false, onResize }); + return
; + }; + + const { getByTestId } = render(); + const target = getByTestId('target'); + const observeCallsForTarget = ResizeObserverMock.instances.flatMap((instance) => + instance.observe.mock.calls.filter(([observedTarget]) => observedTarget === target), + ); + + expect(observeCallsForTarget).toHaveLength(0); + expect(onResize).not.toHaveBeenCalled(); + }); + + it('reads size from borderBox/contentBox before contentRect', async () => { + const onResize = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = () => { + const ref = useResizeObserver({ rafBatch: false, onResize }); + return
; + }; + + const { getByTestId } = render(); + const target = getByTestId('target'); + const observer = getObserverForTarget(target); + + observer.emit([ + createEntry(target, { + width: 10, + height: 20, + borderBoxSize: [{ inlineSize: 300, blockSize: 150 }], + }), + ]); + + expect(onResize).toHaveBeenLastCalledWith( + expect.objectContaining({ + width: 300, + height: 150, + }), + ); + + observer.emit([ + createEntry(target, { + width: 11, + height: 21, + borderBoxSize: [], + contentBoxSize: [{ inlineSize: 400, blockSize: 220 }], + }), + ]); + + expect(onResize).toHaveBeenLastCalledWith( + expect.objectContaining({ + width: 400, + height: 220, + }), + ); + }); + + it('creates separate observers for different box options', async () => { + const onResizeA = vi.fn(); + const onResizeB = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = ({ box, onResize }: { box: ResizeObserverBoxOptions; onResize: () => void }) => { + const ref = useResizeObserver({ box, rafBatch: false, onResize }); + return
; + }; + + const { container } = render( + <> + + + , + ); + + const [contentTarget, borderTarget] = container.querySelectorAll('div'); + const contentObserver = getObserverForTarget(contentTarget); + const borderObserver = getObserverForTarget(borderTarget); + expect(contentObserver).not.toBe(borderObserver); + }); + + it('reuses one ResizeObserver instance for multiple elements with same box', async () => { + const onResizeA = vi.fn(); + const onResizeB = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = ({ testId, onResize }: { testId: string; onResize: () => void }) => { + const ref = useResizeObserver({ rafBatch: false, onResize }); + return
; + }; + + const { getByTestId } = render( + <> + + + , + ); + + const firstTarget = getByTestId('first-target'); + const secondTarget = getByTestId('second-target'); + const firstObserver = getObserverForTarget(firstTarget); + const secondObserver = getObserverForTarget(secondTarget); + + expect(firstObserver).toBe(secondObserver); + expect(firstObserver.observe).toHaveBeenCalledTimes(2); + }); + + it('supports external ref passed to hook', async () => { + const onResize = vi.fn(); + const { useResizeObserver } = await import('./useResizeObserver'); + + const Fixture = () => { + const externalRef = React.useRef(null); + useResizeObserver({ ref: externalRef, rafBatch: false, onResize }); + return
; + }; + + const { getByTestId } = render(); + const target = getByTestId('target'); + const observer = getObserverForTarget(target); + + expect(observer.observe).toHaveBeenCalledWith(target, { box: 'content-box' }); + + observer.emit([createEntry(target, { width: 410, height: 210 })]); + + expect(onResize).toHaveBeenCalledWith( + expect.objectContaining({ + target, + width: 410, + height: 210, + }), + ); + }); +}); diff --git a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx new file mode 100644 index 00000000000..713f4f37c88 --- /dev/null +++ b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx @@ -0,0 +1,250 @@ +import { act, render } from '@testing-library/react'; + +type VisualViewportMock = { + width: number; + height: number; + addEventListener: ReturnType; + removeEventListener: ReturnType; +}; + +function setupVisualViewport(width: number, height: number): VisualViewportMock { + const visualViewport: VisualViewportMock = { + width, + height, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + Object.defineProperty(globalThis.window, 'visualViewport', { + configurable: true, + value: visualViewport, + }); + + return visualViewport; +} + +describe('useWindowResizeObserver', () => { + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const originalVisualViewport = globalThis.window.visualViewport; + const originalInnerWidth = globalThis.window.innerWidth; + const originalInnerHeight = globalThis.window.innerHeight; + + beforeEach(() => { + vi.resetModules(); + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + Object.defineProperty(globalThis.window, 'innerWidth', { configurable: true, value: 1280 }); + Object.defineProperty(globalThis.window, 'innerHeight', { configurable: true, value: 720 }); + Object.defineProperty(globalThis.window, 'visualViewport', { + configurable: true, + value: originalVisualViewport, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + Object.defineProperty(globalThis.window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + Object.defineProperty(globalThis.window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }); + Object.defineProperty(globalThis.window, 'visualViewport', { + configurable: true, + value: originalVisualViewport, + }); + }); + + it('emits initial window size by default', async () => { + const onResize = vi.fn(); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + const Fixture = () => { + useWindowResizeObserver({ onResize }); + return null; + }; + + render(); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith({ + target: globalThis.window, + width: 1280, + height: 720, + }); + }); + + it('does not emit initially when initialEmit=false and reacts to window resize', async () => { + const onResize = vi.fn(); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + const Fixture = () => { + useWindowResizeObserver({ initialEmit: false, rafBatch: false, onResize }); + return null; + }; + + render(); + + expect(onResize).not.toHaveBeenCalled(); + + Object.defineProperty(globalThis.window, 'innerWidth', { configurable: true, value: 900 }); + Object.defineProperty(globalThis.window, 'innerHeight', { configurable: true, value: 500 }); + + await act(async () => { + globalThis.window.dispatchEvent(new Event('resize')); + }); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledWith({ + target: globalThis.window, + width: 900, + height: 500, + }); + }); + + it('does not subscribe when disabled=false', async () => { + const onResize = vi.fn(); + const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener'); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + const Fixture = () => { + useWindowResizeObserver({ enabled: false, onResize }); + return null; + }; + + render(); + + expect(onResize).not.toHaveBeenCalled(); + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function), expect.anything()); + }); + + it('uses visualViewport sizes and listener when enabled', async () => { + const onResize = vi.fn(); + const visualViewport = setupVisualViewport(400, 300); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + const Fixture = () => { + useWindowResizeObserver({ useVisualViewport: true, rafBatch: false, onResize }); + return null; + }; + + render(); + + expect(onResize).toHaveBeenCalledWith({ + target: globalThis.window, + width: 400, + height: 300, + }); + + const resizeHandler = visualViewport.addEventListener.mock.calls[0][1] as EventListener; + visualViewport.width = 360; + visualViewport.height = 280; + + await act(async () => { + resizeHandler(new Event('resize')); + }); + + expect(onResize).toHaveBeenLastCalledWith({ + target: globalThis.window, + width: 360, + height: 280, + }); + }); + + it('batches window resize events and emits latest payload once per RAF', async () => { + const onResize = vi.fn(); + const rafCallbacks = new Map(); + let rafId = 0; + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + rafId += 1; + rafCallbacks.set(rafId, cb); + return rafId; + }); + globalThis.cancelAnimationFrame = vi.fn(); + + const Fixture = () => { + useWindowResizeObserver({ initialEmit: false, onResize }); + return null; + }; + + render(); + + Object.defineProperty(globalThis.window, 'innerWidth', { configurable: true, value: 1000 }); + Object.defineProperty(globalThis.window, 'innerHeight', { configurable: true, value: 700 }); + globalThis.window.dispatchEvent(new Event('resize')); + + Object.defineProperty(globalThis.window, 'innerWidth', { configurable: true, value: 1100 }); + Object.defineProperty(globalThis.window, 'innerHeight', { configurable: true, value: 710 }); + globalThis.window.dispatchEvent(new Event('resize')); + + expect(onResize).not.toHaveBeenCalled(); + expect(globalThis.requestAnimationFrame).toHaveBeenCalledTimes(1); + + await act(async () => { + rafCallbacks.get(1)?.(0); + }); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenLastCalledWith({ + target: globalThis.window, + width: 1100, + height: 710, + }); + }); + + it('cancels pending RAF and detaches listener on unmount', async () => { + const onResize = vi.fn(); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + globalThis.requestAnimationFrame = vi.fn(() => 73); + globalThis.cancelAnimationFrame = vi.fn(); + const removeEventListenerSpy = vi.spyOn(globalThis.window, 'removeEventListener'); + + const Fixture = () => { + useWindowResizeObserver({ initialEmit: false, onResize }); + return null; + }; + + const { unmount } = render(); + globalThis.window.dispatchEvent(new Event('resize')); + unmount(); + + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(73); + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + }); + + it('attaches window resize listener only once for multiple subscribers', async () => { + const onResizeA = vi.fn(); + const onResizeB = vi.fn(); + const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(globalThis.window, 'removeEventListener'); + const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); + + const Fixture = ({ onResize }: { onResize: (payload: unknown) => void }) => { + useWindowResizeObserver({ initialEmit: false, rafBatch: false, onResize }); + return null; + }; + + const first = render(); + const second = render(); + + const addCalls = addEventListenerSpy.mock.calls.filter(([event]) => event === 'resize'); + expect(addCalls).toHaveLength(1); + + first.unmount(); + expect( + removeEventListenerSpy.mock.calls.filter(([event]) => event === 'resize' && removeEventListenerSpy), + ).toHaveLength(0); + + second.unmount(); + const removeCalls = removeEventListenerSpy.mock.calls.filter(([event]) => event === 'resize'); + expect(removeCalls).toHaveLength(1); + }); +}); From e8874c672956e6721654ba50ce5ec9657d429f0d Mon Sep 17 00:00:00 2001 From: "e.mukhametkhanov" Date: Thu, 2 Apr 2026 17:03:49 +0300 Subject: [PATCH 04/14] fix: fix tests and refactor code --- .../docs/common/hooks/useInfiniteList.tsx | 2 +- .../components/FixedLayout/FixedLayout.tsx | 5 +- .../vkui/src/components/Skeleton/Skeleton.tsx | 1 - .../useResizeObserver.test.tsx | 14 ++- .../useWindowResizeObserver.test.tsx | 46 +++----- .../useWindowResizeObserver.ts | 110 +++++++++--------- 6 files changed, 83 insertions(+), 95 deletions(-) diff --git a/packages/vkui/docs/common/hooks/useInfiniteList.tsx b/packages/vkui/docs/common/hooks/useInfiniteList.tsx index da11e57eedf..9a401dfcaeb 100644 --- a/packages/vkui/docs/common/hooks/useInfiniteList.tsx +++ b/packages/vkui/docs/common/hooks/useInfiniteList.tsx @@ -1,7 +1,7 @@ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { Spinner } from '../../../src'; import { useDOM } from '../../../src/lib/dom'; -import { useResizeObserver } from '../../../src/hooks/useResizeObserver/useResizeObserver.ts'; +import { useResizeObserver } from '../../../src/hooks/useResizeObserver/useResizeObserver'; const SPINNER_HEIGHT = 24; const WINDOW_PADDING_BOTTOM = 64; diff --git a/packages/vkui/src/components/FixedLayout/FixedLayout.tsx b/packages/vkui/src/components/FixedLayout/FixedLayout.tsx index b669e49c8fb..44d18591724 100644 --- a/packages/vkui/src/components/FixedLayout/FixedLayout.tsx +++ b/packages/vkui/src/components/FixedLayout/FixedLayout.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { usePlatform } from '../../hooks/usePlatform'; -import { useResizeObserver } from '../../hooks/useResizeObserver/useResizeObserver.ts'; +import { useResizeObserver } from '../../hooks/useResizeObserver/useResizeObserver'; import { useWindowResizeObserver } from '../../hooks/useResizeObserver/useWindowResizeObserver'; import { setRef } from '../../lib/utils'; import { warnOnce } from '../../lib/warnOnce'; @@ -99,11 +99,10 @@ export const FixedLayout = ({ React.useEffect(doResize, [colRef, platform, ref, useParentWidth]); useWindowResizeObserver({ - initialEmit: false, onResize: doResize, }); useResizeObserver({ - ref: useParentWidth ? parentRef : colRef, + ref: useParentWidth ? parentRef : (colRef ?? undefined), onResize: doResize, }); diff --git a/packages/vkui/src/components/Skeleton/Skeleton.tsx b/packages/vkui/src/components/Skeleton/Skeleton.tsx index 317ffb4504b..910f4f37f9c 100644 --- a/packages/vkui/src/components/Skeleton/Skeleton.tsx +++ b/packages/vkui/src/components/Skeleton/Skeleton.tsx @@ -87,7 +87,6 @@ function useSkeletonPosition(rootRef: React.RefObject) { React.useEffect(updatePosition, [updatePosition]); useWindowResizeObserver({ - initialEmit: false, onResize: updatePosition, }); diff --git a/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx index 39831350a21..c0724641721 100644 --- a/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx +++ b/packages/vkui/src/hooks/useResizeObserver/useResizeObserver.test.tsx @@ -49,13 +49,13 @@ function createEntry( }: { width?: number; height?: number; - borderBoxSize?: { inlineSize: number; blockSize: number }[]; - contentBoxSize?: { inlineSize: number; blockSize: number }[]; + borderBoxSize?: Array<{ inlineSize: number; blockSize: number }>; + contentBoxSize?: Array<{ inlineSize: number; blockSize: number }>; } = {}, ): MockResizeObserverEntry { return { target, - contentRect: { width, height } as DOMRectReadOnly, + contentRect: { width, height } as unknown as DOMRectReadOnly, borderBoxSize: borderBoxSize as ResizeObserverSize[], contentBoxSize: contentBoxSize as ResizeObserverSize[], }; @@ -239,7 +239,13 @@ describe('useResizeObserver', () => { const onResizeB = vi.fn(); const { useResizeObserver } = await import('./useResizeObserver'); - const Fixture = ({ box, onResize }: { box: ResizeObserverBoxOptions; onResize: () => void }) => { + const Fixture = ({ + box, + onResize, + }: { + box: ResizeObserverBoxOptions; + onResize: () => void; + }) => { const ref = useResizeObserver({ box, rafBatch: false, onResize }); return
; }; diff --git a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx index 713f4f37c88..41149383639 100644 --- a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx +++ b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.test.tsx @@ -60,31 +60,12 @@ describe('useWindowResizeObserver', () => { }); }); - it('emits initial window size by default', async () => { + it('does not emit initially when and reacts to window resize', async () => { const onResize = vi.fn(); const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); const Fixture = () => { - useWindowResizeObserver({ onResize }); - return null; - }; - - render(); - - expect(onResize).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledWith({ - target: globalThis.window, - width: 1280, - height: 720, - }); - }); - - it('does not emit initially when initialEmit=false and reacts to window resize', async () => { - const onResize = vi.fn(); - const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); - - const Fixture = () => { - useWindowResizeObserver({ initialEmit: false, rafBatch: false, onResize }); + useWindowResizeObserver({ rafBatch: false, onResize }); return null; }; @@ -120,7 +101,11 @@ describe('useWindowResizeObserver', () => { render(); expect(onResize).not.toHaveBeenCalled(); - expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function), expect.anything()); + expect(addEventListenerSpy).not.toHaveBeenCalledWith( + 'resize', + expect.any(Function), + expect.anything(), + ); }); it('uses visualViewport sizes and listener when enabled', async () => { @@ -135,11 +120,7 @@ describe('useWindowResizeObserver', () => { render(); - expect(onResize).toHaveBeenCalledWith({ - target: globalThis.window, - width: 400, - height: 300, - }); + expect(onResize).not.toHaveBeenCalled(); const resizeHandler = visualViewport.addEventListener.mock.calls[0][1] as EventListener; visualViewport.width = 360; @@ -149,6 +130,7 @@ describe('useWindowResizeObserver', () => { resizeHandler(new Event('resize')); }); + expect(onResize).toHaveBeenCalledTimes(1); expect(onResize).toHaveBeenLastCalledWith({ target: globalThis.window, width: 360, @@ -170,7 +152,7 @@ describe('useWindowResizeObserver', () => { globalThis.cancelAnimationFrame = vi.fn(); const Fixture = () => { - useWindowResizeObserver({ initialEmit: false, onResize }); + useWindowResizeObserver({ onResize }); return null; }; @@ -208,7 +190,7 @@ describe('useWindowResizeObserver', () => { const removeEventListenerSpy = vi.spyOn(globalThis.window, 'removeEventListener'); const Fixture = () => { - useWindowResizeObserver({ initialEmit: false, onResize }); + useWindowResizeObserver({ onResize }); return null; }; @@ -228,7 +210,7 @@ describe('useWindowResizeObserver', () => { const { useWindowResizeObserver } = await import('./useWindowResizeObserver'); const Fixture = ({ onResize }: { onResize: (payload: unknown) => void }) => { - useWindowResizeObserver({ initialEmit: false, rafBatch: false, onResize }); + useWindowResizeObserver({ rafBatch: false, onResize }); return null; }; @@ -240,7 +222,9 @@ describe('useWindowResizeObserver', () => { first.unmount(); expect( - removeEventListenerSpy.mock.calls.filter(([event]) => event === 'resize' && removeEventListenerSpy), + removeEventListenerSpy.mock.calls.filter( + ([event]) => event === 'resize' && removeEventListenerSpy, + ), ).toHaveLength(0); second.unmount(); diff --git a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts index 157666d87eb..60c1f8aeff1 100644 --- a/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts +++ b/packages/vkui/src/hooks/useResizeObserver/useWindowResizeObserver.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useDOM } from '../../lib/dom'; import { useStableCallback } from '../useStableCallback'; export type WindowResizePayload = { @@ -11,7 +12,6 @@ export type WindowResizeOptions = { enabled?: boolean; rafBatch?: boolean; useVisualViewport?: boolean; - initialEmit?: boolean; onResize: (payload: WindowResizePayload) => void; }; @@ -26,9 +26,19 @@ type WindowSubscriber = { const windowSubscribers = new Set(); let windowListenerAttached = false; let visualViewportListenerAttached = false; +let windowResizeHandler: (() => void) | null = null; +let visualViewportResizeHandler: (() => void) | null = null; -function getWindowSize(useVisualViewport: boolean) { - const visualViewport = useVisualViewport ? globalThis.window.visualViewport : null; +const windowSubscriberPredicate = (subscriber: WindowSubscriber) => !subscriber.useVisualViewport; +const visualViewportSubscriberPredicate = (subscriber: WindowSubscriber) => + subscriber.useVisualViewport; + +const hasWindowSubscribers = () => [...windowSubscribers].some(windowSubscriberPredicate); +const hasVisualViewportSubscribers = () => + [...windowSubscribers].some(visualViewportSubscriberPredicate); + +function getWindowSize(window: Window, useVisualViewport: boolean) { + const visualViewport = useVisualViewport ? window.visualViewport : null; if (visualViewport !== null) { return { @@ -38,21 +48,24 @@ function getWindowSize(useVisualViewport: boolean) { } return { - width: globalThis.window.innerWidth, - height: globalThis.window.innerHeight, + width: window.innerWidth, + height: window.innerHeight, }; } -function notifySubscribers(subscriberPredicate: (subscriber: WindowSubscriber) => boolean) { +function notifySubscribers( + window: Window, + subscriberPredicate: (subscriber: WindowSubscriber) => boolean, +) { for (const sub of windowSubscribers) { if (!subscriberPredicate(sub)) { continue; } - const size = getWindowSize(sub.useVisualViewport); + const size = getWindowSize(window, sub.useVisualViewport); const emit = () => { sub.onResize({ - target: globalThis.window, + target: window, width: size.width, height: size.height, }); @@ -78,7 +91,7 @@ function notifySubscribers(subscriberPredicate: (subscriber: WindowSubscriber) = sub.pendingRef.current = null; sub.onResize({ - target: globalThis.window, + target: window, width: pending.width, height: pending.height, }); @@ -86,70 +99,64 @@ function notifySubscribers(subscriberPredicate: (subscriber: WindowSubscriber) = } } -function notifyWindowSubscribers() { - notifySubscribers((sub) => !sub.useVisualViewport); +function notifyWindowSubscribers(window: Window) { + notifySubscribers(window, windowSubscriberPredicate); } -function notifyVisualViewportSubscribers() { - notifySubscribers((sub) => sub.useVisualViewport); +function notifyVisualViewportSubscribers(window: Window) { + notifySubscribers(window, visualViewportSubscriberPredicate); } -function ensureWindowListener() { - if (windowListenerAttached) { - return; - } - if (![...windowSubscribers].some((sub) => !sub.useVisualViewport)) { +function ensureWindowListener(window: Window) { + if (windowListenerAttached || !hasWindowSubscribers()) { return; } - globalThis.window.addEventListener('resize', notifyWindowSubscribers, { passive: true }); + windowResizeHandler = () => notifyWindowSubscribers(window); + window.addEventListener('resize', windowResizeHandler, { passive: true }); windowListenerAttached = true; } -function ensureVisualViewportListener() { - if (visualViewportListenerAttached) { - return; - } - if (![...windowSubscribers].some((sub) => sub.useVisualViewport)) { +function ensureVisualViewportListener(window: Window) { + if (visualViewportListenerAttached || !hasVisualViewportSubscribers()) { return; } - globalThis.window.visualViewport?.addEventListener('resize', notifyVisualViewportSubscribers, { + visualViewportResizeHandler = () => notifyVisualViewportSubscribers(window); + window.visualViewport?.addEventListener('resize', visualViewportResizeHandler, { passive: true, }); visualViewportListenerAttached = true; } -function maybeDetachWindowListener() { - if (!windowListenerAttached) { - return; - } - if ([...windowSubscribers].some((sub) => !sub.useVisualViewport)) { +function maybeDetachWindowListener(window: Window) { + if (!windowListenerAttached || !windowResizeHandler || hasWindowSubscribers()) { return; } - - globalThis.window.removeEventListener('resize', notifyWindowSubscribers); + window.removeEventListener('resize', windowResizeHandler); windowListenerAttached = false; + windowResizeHandler = null; } -function maybeDetachVisualViewportListener() { - if (!visualViewportListenerAttached) { +function maybeDetachVisualViewportListener(window: Window) { + if ( + !visualViewportListenerAttached || + !visualViewportResizeHandler || + hasVisualViewportSubscribers() + ) { return; } - if ([...windowSubscribers].some((sub) => sub.useVisualViewport)) { - return; - } - - globalThis.window.visualViewport?.removeEventListener('resize', notifyVisualViewportSubscribers); + window.visualViewport?.removeEventListener('resize', visualViewportResizeHandler); visualViewportListenerAttached = false; + visualViewportResizeHandler = null; } export function useWindowResizeObserver(options: WindowResizeOptions) { + const { window } = useDOM(); const { enabled = true, rafBatch = true, useVisualViewport = false, - initialEmit = true, onResize: onResizeProp, } = options; @@ -158,10 +165,12 @@ export function useWindowResizeObserver(options: WindowResizeOptions) { const pendingRef = React.useRef<{ width: number; height: number } | null>(null); React.useEffect(() => { - if (!enabled) { + if (!enabled || !window) { return; } + const resolvedWindow = window; + const sub: WindowSubscriber = { onResize, rafBatch, @@ -172,18 +181,9 @@ export function useWindowResizeObserver(options: WindowResizeOptions) { windowSubscribers.add(sub); if (useVisualViewport) { - ensureVisualViewportListener(); + ensureVisualViewportListener(resolvedWindow); } else { - ensureWindowListener(); - } - - if (initialEmit) { - const size = getWindowSize(useVisualViewport); - onResize({ - target: globalThis.window, - width: size.width, - height: size.height, - }); + ensureWindowListener(resolvedWindow); } return () => { @@ -194,8 +194,8 @@ export function useWindowResizeObserver(options: WindowResizeOptions) { pendingRef.current = null; windowSubscribers.delete(sub); - maybeDetachVisualViewportListener(); - maybeDetachWindowListener(); + maybeDetachVisualViewportListener(resolvedWindow); + maybeDetachWindowListener(resolvedWindow); }; - }, [enabled, rafBatch, useVisualViewport, initialEmit, onResize]); + }, [enabled, rafBatch, useVisualViewport, onResize, window]); } From 1a77f8e134add5d619d1af88a28c6e80c4626067 Mon Sep 17 00:00:00 2001 From: "e.mukhametkhanov" Date: Thu, 2 Apr 2026 19:34:50 +0300 Subject: [PATCH 05/14] test: fix tests --- .../FixedLayout/FixedLayout.test.tsx | 36 +++--- .../src/components/Gallery/Gallery.test.tsx | 54 +++------ .../src/components/Textarea/Textarea.test.tsx | 7 +- .../useResizeObserver.test.tsx | 47 ++------ .../vkui/src/hooks/useVirtualKeyboardState.ts | 2 + packages/vkui/src/lib/randomUUID.test.ts | 11 +- packages/vkui/vitest.setup.ts | 103 +++++++++++++++++- 7 files changed, 145 insertions(+), 115 deletions(-) diff --git a/packages/vkui/src/components/FixedLayout/FixedLayout.test.tsx b/packages/vkui/src/components/FixedLayout/FixedLayout.test.tsx index 27d58a341f5..5a909aba2fb 100644 --- a/packages/vkui/src/components/FixedLayout/FixedLayout.test.tsx +++ b/packages/vkui/src/components/FixedLayout/FixedLayout.test.tsx @@ -1,30 +1,14 @@ import { act, type RefObject } from 'react'; import { render } from '@testing-library/react'; -import { baselineComponent } from '../../testing/utils'; +import {baselineComponent, withFakeTimers} from '../../testing/utils'; import { SplitCol } from '../SplitCol/SplitCol'; import { FixedLayout, type FixedLayoutProps } from './FixedLayout'; import styles from './FixedLayout.module.css'; -let updateFunction: () => void; - -const mockResizeObserver = vi.fn( - class MockResizeObserver { - constructor(updateFunctionFn: () => void) { - updateFunction = updateFunctionFn; - } - - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - }, -); - -vi.stubGlobal('ResizeObserver', mockResizeObserver); - describe('FixedLayout', () => { baselineComponent(FixedLayout); - it('check update width by parent width', async () => { + it('check update width by parent width', withFakeTimers(async () => { const parentRef: RefObject = { current: null, }; @@ -55,12 +39,15 @@ describe('FixedLayout', () => { expect(layoutRef.current!).toHaveStyle('width: 500px'); parentWidth = 600; - await act(async () => updateFunction()); + act(() => { + globalThis.__resizeObserverMock.triggerAll(); + vi.runAllTimers(); + }); expect(layoutRef.current!).toHaveStyle('width: 600px'); - }); + })); - it('check update width by column width', async () => { + it('check update width by column width', withFakeTimers(async () => { const colRef: RefObject = { current: null, }; @@ -89,10 +76,13 @@ describe('FixedLayout', () => { expect(layoutRef.current!).toHaveStyle('width: 280px'); colWidth = 360; - await act(async () => updateFunction()); + act(() => { + globalThis.__resizeObserverMock.triggerAll(); + vi.runAllTimers(); + }); expect(layoutRef.current!).toHaveStyle('width: 360px'); - }); + })); describe('check correct classNames', () => { it.each<{ props: Partial; className: string }>([ diff --git a/packages/vkui/src/components/Gallery/Gallery.test.tsx b/packages/vkui/src/components/Gallery/Gallery.test.tsx index cb42ad0c373..c40162778fc 100644 --- a/packages/vkui/src/components/Gallery/Gallery.test.tsx +++ b/packages/vkui/src/components/Gallery/Gallery.test.tsx @@ -492,8 +492,10 @@ describe('Gallery', () => { mockedData.containerWidth = 250; - fireEvent.resize(window); - vi.runAllTimers(); + act(() => { + fireEvent.resize(window); + vi.runAllTimers(); + }); if (looped) { expect(mockedData.layerTransform).toBe('translate3d(35px, 0, 0)'); @@ -764,41 +766,9 @@ describe('Gallery', () => { }); }); - const mockResizeObserver = () => { - const callbacks = new Set(); - - class MockResizeObserver implements ResizeObserver { - constructor(callback: ResizeObserverCallback) { - callbacks.add(callback); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - observe() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - unobserve() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - disconnect() {} - } - - const originalResizeObserver = window.ResizeObserver; - window.ResizeObserver = MockResizeObserver; - - return { - triggerResize: () => { - callbacks.forEach((callback) => { - callback([], {} as unknown as ResizeObserver); - }); - }, - restore: () => { - window.ResizeObserver = originalResizeObserver; - }, - }; - }; - it( 'check recalculate slides positions when resize element with resizeSource="element"', withFakeTimers(() => { - const { triggerResize, restore } = mockResizeObserver(); const onChange = vi.fn(); const mockedData = setup({ @@ -818,12 +788,13 @@ describe('Gallery', () => { mockedData.containerWidth = 250; - act(triggerResize); - vi.runAllTimers(); + act(() => { + globalThis.__resizeObserverMock.triggerAll(); + vi.runAllTimers(); + }); expect(mockedData.layerTransform).toBe('translate3d(35px, 0, 0)'); expect(mockedData.getSlideMockData(0).transform).toBe('translate3d(0px, 0, 0)'); - restore(); }), ); @@ -885,10 +856,11 @@ describe('Gallery', () => { mockedData.viewPortWidth = 540; onDragStart.mockClear(); onDragEnd.mockClear(); - fireEvent.resize(window); - - rerender({ slideIndex: 1 }); - vi.runAllTimers(); + act(() => { + fireEvent.resize(window); + rerender({ slideIndex: 1 }); + vi.runAllTimers(); + }); expect(getArrows()).toHaveLength(0); diff --git a/packages/vkui/src/components/Textarea/Textarea.test.tsx b/packages/vkui/src/components/Textarea/Textarea.test.tsx index c507e09ba9a..9c6a596b740 100644 --- a/packages/vkui/src/components/Textarea/Textarea.test.tsx +++ b/packages/vkui/src/components/Textarea/Textarea.test.tsx @@ -1,4 +1,4 @@ -import { createRef } from 'react'; +import { act, createRef } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { noop } from '@vkontakte/vkjs'; import { Platform } from '../../lib/platform'; @@ -235,7 +235,10 @@ describe(Textarea, () => { const onResize = vi.fn(); render(