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
aae5434451.
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.
This commit is contained in:
undergroundwires
2024-05-20 10:36:49 +02:00
parent aae5434451
commit 292362135d
12 changed files with 410 additions and 47 deletions

View File

@@ -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);
}

View File

@@ -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<typeof requestAnimationFrame>;
export type AnimationFrameRequestCallback = () => void;
export interface AnimationFrameLimiter {
cancelNextFrame(): void;
resetNextFrame(callback: AnimationFrameRequestCallback): void;
}

View File

@@ -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<Ref<HTMLElement | undefined>>;
export type LifecycleHookRegistration = (callback: () => void) => void;

View File

@@ -16,11 +16,17 @@ async function polyfillResizeObserver(): Promise<typeof ResizeObserver> {
return polyfillLoader.getValue();
}
interface ResizeObserverCreator {
(
...args: ConstructorParameters<typeof ResizeObserver>
): ResizeObserver;
}
export function useResizeObserverPolyfill() {
const resizeObserverReady = new Promise<void>((resolve) => {
const resizeObserverReady = new Promise<ResizeObserverCreator>((resolve) => {
onMounted(async () => {
await polyfillResizeObserver();
resolve();
resolve((args) => new ResizeObserver(args));
});
});
return { resizeObserverReady };

View File

@@ -6,10 +6,9 @@
<script lang="ts">
import {
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
defineComponent, shallowRef, onMounted, watch,
} from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { throttle } from '@/application/Common/Timing/Throttle';
import { useResizeObserver } from './Hooks/Resize/UseResizeObserver';
export default defineComponent({
emits: {
@@ -20,31 +19,21 @@ export default defineComponent({
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
const { resizeObserverReady } = useResizeObserverPolyfill();
const containerElement = shallowRef<HTMLElement>();
let width = 0;
let height = 0;
let observer: ResizeObserver | undefined;
onMounted(() => {
watch(() => containerElement.value, async (element) => {
if (!element) {
disposeObserver();
return;
}
resizeObserverReady.then(() => {
disposeObserver();
observer = new ResizeObserver(throttle(updateSize, 200));
observer.observe(element);
});
updateSize(); // Do not throttle, immediately inform new width
}, { immediate: true });
useResizeObserver({
observedElementRef: containerElement,
observeCallback: updateSize,
throttleInMs: 200,
});
onBeforeUnmount(() => {
disposeObserver();
onMounted(() => {
watch(() => containerElement.value, async () => {
updateSize();
}, { immediate: true });
});
function updateSize() {
@@ -81,11 +70,6 @@ export default defineComponent({
return { isChanged: true };
}
function disposeObserver() {
observer?.disconnect();
observer = undefined;
}
return {
containerElement,
};

View File

@@ -34,7 +34,7 @@ import {
useFloating, arrow, shift, flip, type Placement, offset, type Side, type Coords, autoUpdate,
} from '@floating-ui/vue';
import { defineComponent, shallowRef, computed } from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserverPolyfill';
import { throttle } from '@/application/Common/Timing/Throttle';
import { type TargetEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener';
import { injectKey } from '@/presentation/injectionSymbols';