From 292362135db0519ec1050bab80ed373aad115731 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Mon, 20 May 2024 10:36:49 +0200 Subject: [PATCH] Centralize and optimize `ResizeObserver` usage This commit addresses failures in end-to-end tests that occurred due to `ResizeObserver` loop limit exceptions. These errors were triggered by Vue dependency upgrades in the commit aae54344511ec51d17ad0420a92cb5a064e0e7bb. The errors had the following message: > `ResizeObserver loop completed with undelivered notifications` This error happens when there are too many observations and the observer is not able to deliver all observations within a single animation frame. See: WICG/resize-observer#38 his commit resolves the issue by controlling how many observations are delivered per animation frame and limiting it to only one. It improves performance by reducing layout trashing, improving frame rates, and managing resources more effectively. Changes: - Introduce an animation frame control to manage observations more efficiently. - Centralized `ResizeObserver` management within the `UseResizeObserver` hook to improve consistency and reuse across the application. --- src/application/Common/Timing/Throttle.ts | 14 +- .../DevToolkit/UseScrollbarGutterWidth.ts | 21 ++- .../Hooks/Resize/UseAnimationFrameLimiter.ts | 41 ++++++ .../Shared/Hooks/Resize/UseResizeObserver.ts | 67 +++++++++ .../{ => Resize}/UseResizeObserverPolyfill.ts | 10 +- .../components/Shared/SizeObserver.vue | 36 ++--- .../components/Shared/TooltipWrapper.vue | 2 +- .../Common/Timing/Throttle.spec.ts | 6 +- .../Scripts/Slider/UseDragHandler.spec.ts | 4 +- .../Resize/UseAnimationFrameLimiter.spec.ts | 133 ++++++++++++++++++ .../Shared/Resize/UseResizeObserver.spec.ts | 117 +++++++++++++++ tests/unit/shared/Stubs/ThrottleStub.ts | 6 +- 12 files changed, 410 insertions(+), 47 deletions(-) create mode 100644 src/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter.ts create mode 100644 src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts rename src/presentation/components/Shared/Hooks/{ => Resize}/UseResizeObserverPolyfill.ts (75%) create mode 100644 tests/unit/presentation/components/Shared/Resize/UseAnimationFrameLimiter.spec.ts create mode 100644 tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts diff --git a/src/application/Common/Timing/Throttle.ts b/src/application/Common/Timing/Throttle.ts index da3bb732..fa95d087 100644 --- a/src/application/Common/Timing/Throttle.ts +++ b/src/application/Common/Timing/Throttle.ts @@ -15,18 +15,26 @@ const DefaultOptions: ThrottleOptions = { timer: PlatformTimer, }; -export function throttle( +export interface ThrottleFunction { + ( + callback: CallbackType, + waitInMs: number, + options?: Partial, + ): CallbackType; +} + +export const throttle: ThrottleFunction = ( callback: CallbackType, waitInMs: number, options: Partial = DefaultOptions, -): CallbackType { +): CallbackType => { const defaultedOptions: ThrottleOptions = { ...DefaultOptions, ...options, }; const throttler = new Throttler(waitInMs, callback, defaultedOptions); return (...args: unknown[]) => throttler.invoke(...args); -} +}; class Throttler { private lastExecutionTime: number | null = null; diff --git a/src/presentation/components/DevToolkit/UseScrollbarGutterWidth.ts b/src/presentation/components/DevToolkit/UseScrollbarGutterWidth.ts index 18046699..b96e8f52 100644 --- a/src/presentation/components/DevToolkit/UseScrollbarGutterWidth.ts +++ b/src/presentation/components/DevToolkit/UseScrollbarGutterWidth.ts @@ -1,8 +1,9 @@ import { - computed, readonly, ref, watch, + computed, readonly, ref, shallowRef, watch, } from 'vue'; import { throttle } from '@/application/Common/Timing/Throttle'; import { useAutoUnsubscribedEventListener } from '../Shared/Hooks/UseAutoUnsubscribedEventListener'; +import { useResizeObserver } from '../Shared/Hooks/Resize/UseResizeObserver'; const RESIZE_EVENT_THROTTLE_MS = 200; @@ -29,11 +30,17 @@ function getScrollbarGutterWidth(): number { function useBodyWidth() { const width = ref(document.body.offsetWidth); - const observer = new ResizeObserver((entries) => throttle(() => { - for (const entry of entries) { - width.value = entry.borderBoxSize[0].inlineSize; - } - }, RESIZE_EVENT_THROTTLE_MS)); - observer.observe(document.body, { box: 'border-box' }); + useResizeObserver( + { + observedElementRef: shallowRef(document.body), + throttleInMs: RESIZE_EVENT_THROTTLE_MS, + observeCallback: (entries) => { + for (const entry of entries) { + width.value = entry.borderBoxSize[0].inlineSize; + } + }, + observeOptions: { box: 'border-box' }, + }, + ); return readonly(width); } diff --git a/src/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter.ts b/src/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter.ts new file mode 100644 index 00000000..92f1aff4 --- /dev/null +++ b/src/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter.ts @@ -0,0 +1,41 @@ +import { onBeforeUnmount } from 'vue'; + +export function useAnimationFrameLimiter( + cancelAnimationFrame: CancelAnimationFrameFunction = window.cancelAnimationFrame, + requestAnimationFrame: RequestAnimationFrameFunction = window.requestAnimationFrame, + onTeardown: RegisterTeardownCallbackFunction = onBeforeUnmount, +): AnimationFrameLimiter { + let requestId: AnimationFrameId | null = null; + const cancelNextFrame = () => { + if (requestId === null) { + return; + } + cancelAnimationFrame(requestId); + }; + const resetNextFrame = (callback: AnimationFrameRequestCallback) => { + cancelNextFrame(); + requestId = requestAnimationFrame(callback); + }; + onTeardown(() => { + cancelNextFrame(); + }); + return { + cancelNextFrame, + resetNextFrame, + }; +} + +export type CancelAnimationFrameFunction = typeof window.cancelAnimationFrame; + +export type RequestAnimationFrameFunction = (callback: AnimationFrameRequestCallback) => number; + +export type RegisterTeardownCallbackFunction = (callback: () => void) => void; + +export type AnimationFrameId = ReturnType; + +export type AnimationFrameRequestCallback = () => void; + +export interface AnimationFrameLimiter { + cancelNextFrame(): void; + resetNextFrame(callback: AnimationFrameRequestCallback): void; +} diff --git a/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts new file mode 100644 index 00000000..0ee22f8b --- /dev/null +++ b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts @@ -0,0 +1,67 @@ +import { + onBeforeMount, onBeforeUnmount, + watch, type Ref, +} from 'vue'; +import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle'; +import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill'; +import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter'; + +export function useResizeObserver( + config: ResizeObserverConfig, + usePolyfill = useResizeObserverPolyfill, + useFrameLimiter = useAnimationFrameLimiter, + throttler: ThrottleFunction = throttle, + onSetup: LifecycleHookRegistration = onBeforeMount, + onTeardown: LifecycleHookRegistration = onBeforeUnmount, +) { + const { resetNextFrame, cancelNextFrame } = useFrameLimiter(); + // This prevents the 'ResizeObserver loop completed with undelivered notifications' error when + // the browser can't process all observations within one animation frame. + // Reference: https://github.com/WICG/resize-observer/issues/38 + + const { resizeObserverReady } = usePolyfill(); + // This ensures compatibility with ancient browsers. All modern browsers support ResizeObserver. + // Compatibility info: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#browser_compatibility + + const throttledCallback = throttler(config.observeCallback, config.throttleInMs); + // Throttling enhances performance during rapid changes such as window resizing. + + let observer: ResizeObserver | null; + + const disposeObserver = () => { + cancelNextFrame(); + observer?.disconnect(); + observer = null; + }; + + onSetup(() => { + watch(() => config.observedElementRef.value, (element) => { + if (!element) { + disposeObserver(); + return; + } + resizeObserverReady.then((createObserver) => { + disposeObserver(); + observer = createObserver((...args) => { + resetNextFrame(() => throttledCallback(...args)); + }); + observer.observe(element, config?.observeOptions); + }); + }, { immediate: true }); + }); + + onTeardown(() => { + disposeObserver(); + }); +} + +export interface ResizeObserverConfig { + readonly observedElementRef: ObservedElementReference; + readonly throttleInMs: number; + readonly observeCallback: ResizeObserverCallback; + readonly observeOptions?: ResizeObserverOptions; +} + +export type ObservedElementReference = Readonly>; + +export type LifecycleHookRegistration = (callback: () => void) => void; diff --git a/src/presentation/components/Shared/Hooks/UseResizeObserverPolyfill.ts b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserverPolyfill.ts similarity index 75% rename from src/presentation/components/Shared/Hooks/UseResizeObserverPolyfill.ts rename to src/presentation/components/Shared/Hooks/Resize/UseResizeObserverPolyfill.ts index 738dadc4..ab3819b5 100644 --- a/src/presentation/components/Shared/Hooks/UseResizeObserverPolyfill.ts +++ b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserverPolyfill.ts @@ -16,11 +16,17 @@ async function polyfillResizeObserver(): Promise { return polyfillLoader.getValue(); } +interface ResizeObserverCreator { + ( + ...args: ConstructorParameters + ): ResizeObserver; +} + export function useResizeObserverPolyfill() { - const resizeObserverReady = new Promise((resolve) => { + const resizeObserverReady = new Promise((resolve) => { onMounted(async () => { await polyfillResizeObserver(); - resolve(); + resolve((args) => new ResizeObserver(args)); }); }); return { resizeObserverReady }; diff --git a/src/presentation/components/Shared/SizeObserver.vue b/src/presentation/components/Shared/SizeObserver.vue index 593da74d..ef4eeae6 100644 --- a/src/presentation/components/Shared/SizeObserver.vue +++ b/src/presentation/components/Shared/SizeObserver.vue @@ -6,10 +6,9 @@