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 @@