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:
@@ -15,18 +15,26 @@ const DefaultOptions: ThrottleOptions = {
|
|||||||
timer: PlatformTimer,
|
timer: PlatformTimer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function throttle(
|
export interface ThrottleFunction {
|
||||||
|
(
|
||||||
|
callback: CallbackType,
|
||||||
|
waitInMs: number,
|
||||||
|
options?: Partial<ThrottleOptions>,
|
||||||
|
): CallbackType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const throttle: ThrottleFunction = (
|
||||||
callback: CallbackType,
|
callback: CallbackType,
|
||||||
waitInMs: number,
|
waitInMs: number,
|
||||||
options: Partial<ThrottleOptions> = DefaultOptions,
|
options: Partial<ThrottleOptions> = DefaultOptions,
|
||||||
): CallbackType {
|
): CallbackType => {
|
||||||
const defaultedOptions: ThrottleOptions = {
|
const defaultedOptions: ThrottleOptions = {
|
||||||
...DefaultOptions,
|
...DefaultOptions,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
const throttler = new Throttler(waitInMs, callback, defaultedOptions);
|
const throttler = new Throttler(waitInMs, callback, defaultedOptions);
|
||||||
return (...args: unknown[]) => throttler.invoke(...args);
|
return (...args: unknown[]) => throttler.invoke(...args);
|
||||||
}
|
};
|
||||||
|
|
||||||
class Throttler {
|
class Throttler {
|
||||||
private lastExecutionTime: number | null = null;
|
private lastExecutionTime: number | null = null;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
computed, readonly, ref, watch,
|
computed, readonly, ref, shallowRef, watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { throttle } from '@/application/Common/Timing/Throttle';
|
import { throttle } from '@/application/Common/Timing/Throttle';
|
||||||
import { useAutoUnsubscribedEventListener } from '../Shared/Hooks/UseAutoUnsubscribedEventListener';
|
import { useAutoUnsubscribedEventListener } from '../Shared/Hooks/UseAutoUnsubscribedEventListener';
|
||||||
|
import { useResizeObserver } from '../Shared/Hooks/Resize/UseResizeObserver';
|
||||||
|
|
||||||
const RESIZE_EVENT_THROTTLE_MS = 200;
|
const RESIZE_EVENT_THROTTLE_MS = 200;
|
||||||
|
|
||||||
@@ -29,11 +30,17 @@ function getScrollbarGutterWidth(): number {
|
|||||||
|
|
||||||
function useBodyWidth() {
|
function useBodyWidth() {
|
||||||
const width = ref(document.body.offsetWidth);
|
const width = ref(document.body.offsetWidth);
|
||||||
const observer = new ResizeObserver((entries) => throttle(() => {
|
useResizeObserver(
|
||||||
|
{
|
||||||
|
observedElementRef: shallowRef(document.body),
|
||||||
|
throttleInMs: RESIZE_EVENT_THROTTLE_MS,
|
||||||
|
observeCallback: (entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
width.value = entry.borderBoxSize[0].inlineSize;
|
width.value = entry.borderBoxSize[0].inlineSize;
|
||||||
}
|
}
|
||||||
}, RESIZE_EVENT_THROTTLE_MS));
|
},
|
||||||
observer.observe(document.body, { box: 'border-box' });
|
observeOptions: { box: 'border-box' },
|
||||||
|
},
|
||||||
|
);
|
||||||
return readonly(width);
|
return readonly(width);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -16,11 +16,17 @@ async function polyfillResizeObserver(): Promise<typeof ResizeObserver> {
|
|||||||
return polyfillLoader.getValue();
|
return polyfillLoader.getValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResizeObserverCreator {
|
||||||
|
(
|
||||||
|
...args: ConstructorParameters<typeof ResizeObserver>
|
||||||
|
): ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
export function useResizeObserverPolyfill() {
|
export function useResizeObserverPolyfill() {
|
||||||
const resizeObserverReady = new Promise<void>((resolve) => {
|
const resizeObserverReady = new Promise<ResizeObserverCreator>((resolve) => {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await polyfillResizeObserver();
|
await polyfillResizeObserver();
|
||||||
resolve();
|
resolve((args) => new ResizeObserver(args));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { resizeObserverReady };
|
return { resizeObserverReady };
|
||||||
@@ -6,10 +6,9 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
|
defineComponent, shallowRef, onMounted, watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
import { useResizeObserver } from './Hooks/Resize/UseResizeObserver';
|
||||||
import { throttle } from '@/application/Common/Timing/Throttle';
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: {
|
emits: {
|
||||||
@@ -20,33 +19,23 @@ export default defineComponent({
|
|||||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
},
|
},
|
||||||
setup(_, { emit }) {
|
setup(_, { emit }) {
|
||||||
const { resizeObserverReady } = useResizeObserverPolyfill();
|
|
||||||
|
|
||||||
const containerElement = shallowRef<HTMLElement>();
|
const containerElement = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
let width = 0;
|
let width = 0;
|
||||||
let height = 0;
|
let height = 0;
|
||||||
let observer: ResizeObserver | undefined;
|
|
||||||
|
useResizeObserver({
|
||||||
|
observedElementRef: containerElement,
|
||||||
|
observeCallback: updateSize,
|
||||||
|
throttleInMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
watch(() => containerElement.value, async (element) => {
|
watch(() => containerElement.value, async () => {
|
||||||
if (!element) {
|
updateSize();
|
||||||
disposeObserver();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resizeObserverReady.then(() => {
|
|
||||||
disposeObserver();
|
|
||||||
observer = new ResizeObserver(throttle(updateSize, 200));
|
|
||||||
observer.observe(element);
|
|
||||||
});
|
|
||||||
updateSize(); // Do not throttle, immediately inform new width
|
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
disposeObserver();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateSize() {
|
function updateSize() {
|
||||||
const changes = [
|
const changes = [
|
||||||
updateWidth(),
|
updateWidth(),
|
||||||
@@ -81,11 +70,6 @@ export default defineComponent({
|
|||||||
return { isChanged: true };
|
return { isChanged: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeObserver() {
|
|
||||||
observer?.disconnect();
|
|
||||||
observer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containerElement,
|
containerElement,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
useFloating, arrow, shift, flip, type Placement, offset, type Side, type Coords, autoUpdate,
|
useFloating, arrow, shift, flip, type Placement, offset, type Side, type Coords, autoUpdate,
|
||||||
} from '@floating-ui/vue';
|
} from '@floating-ui/vue';
|
||||||
import { defineComponent, shallowRef, computed } from '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 { throttle } from '@/application/Common/Timing/Throttle';
|
||||||
import { type TargetEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener';
|
import { type TargetEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener';
|
||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
|
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
|
||||||
import { throttle, type ThrottleOptions } from '@/application/Common/Timing/Throttle';
|
import { throttle, type ThrottleFunction, type ThrottleOptions } from '@/application/Common/Timing/Throttle';
|
||||||
import type { Timer } from '@/application/Common/Timing/Timer';
|
import type { Timer } from '@/application/Common/Timing/Timer';
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ describe('throttle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type CallbackType = Parameters<typeof throttle>[0];
|
type CallbackType = Parameters<ThrottleFunction>[0];
|
||||||
|
|
||||||
class TestContext {
|
class TestContext {
|
||||||
private options: Partial<ThrottleOptions> | undefined = {
|
private options: Partial<ThrottleOptions> | undefined = {
|
||||||
@@ -315,7 +315,7 @@ class TestContext {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public throttle(): ReturnType<typeof throttle> {
|
public throttle(): ReturnType<ThrottleFunction> {
|
||||||
return throttle(
|
return throttle(
|
||||||
this.callback,
|
this.callback,
|
||||||
this.waitInMs,
|
this.waitInMs,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { ref, type Ref } from 'vue';
|
import { ref, type Ref } from 'vue';
|
||||||
import { useDragHandler, type DragDomModifier } from '@/presentation/components/Scripts/Slider/UseDragHandler';
|
import { useDragHandler, type DragDomModifier } from '@/presentation/components/Scripts/Slider/UseDragHandler';
|
||||||
import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub';
|
import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub';
|
||||||
import { throttle } from '@/application/Common/Timing/Throttle';
|
import { type ThrottleFunction } from '@/application/Common/Timing/Throttle';
|
||||||
import type { ConstructorArguments } from '@/TypeHelpers';
|
import type { ConstructorArguments } from '@/TypeHelpers';
|
||||||
|
|
||||||
describe('useDragHandler', () => {
|
describe('useDragHandler', () => {
|
||||||
@@ -235,7 +235,7 @@ describe('useDragHandler', () => {
|
|||||||
function initializeDragHandlerWithMocks(mocks?: {
|
function initializeDragHandlerWithMocks(mocks?: {
|
||||||
readonly dragDomModifier?: DragDomModifier;
|
readonly dragDomModifier?: DragDomModifier;
|
||||||
readonly draggableElementRef?: Ref<HTMLElement>;
|
readonly draggableElementRef?: Ref<HTMLElement>;
|
||||||
readonly throttler?: typeof throttle,
|
readonly throttler?: ThrottleFunction,
|
||||||
}) {
|
}) {
|
||||||
return useDragHandler(
|
return useDragHandler(
|
||||||
mocks?.draggableElementRef ?? ref(document.createElement('div')),
|
mocks?.draggableElementRef ?? ref(document.createElement('div')),
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
useAnimationFrameLimiter, type AnimationFrameId, type AnimationFrameRequestCallback,
|
||||||
|
type CancelAnimationFrameFunction, type RegisterTeardownCallbackFunction,
|
||||||
|
type RequestAnimationFrameFunction,
|
||||||
|
} from '@/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter';
|
||||||
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
|
||||||
|
describe('useAnimationFrameLimiter', () => {
|
||||||
|
describe('resetNextFrame', () => {
|
||||||
|
it('schedules the callback in the next animation frame', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCallback = () => {};
|
||||||
|
let scheduledCallback: AnimationFrameRequestCallback | undefined;
|
||||||
|
const requestAnimationFrame: RequestAnimationFrameFunction = (callback) => {
|
||||||
|
scheduledCallback = callback;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
const context = new TestContext()
|
||||||
|
.withRequestAnimationFrameFunction(requestAnimationFrame);
|
||||||
|
// act
|
||||||
|
const { resetNextFrame } = context.useAnimationFrameLimiter();
|
||||||
|
resetNextFrame(expectedCallback);
|
||||||
|
// assert
|
||||||
|
expect(scheduledCallback).to.equal(expectedCallback);
|
||||||
|
});
|
||||||
|
it('cancels the existing animation frame before scheduling a new one', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCancelledAnimationFrameId: AnimationFrameId = 5;
|
||||||
|
let actualCancelledAnimationFrameId: AnimationFrameId | undefined;
|
||||||
|
const requestAnimationFrame: RequestAnimationFrameFunction = () => {
|
||||||
|
return expectedCancelledAnimationFrameId;
|
||||||
|
};
|
||||||
|
const cancelAnimationFrame: CancelAnimationFrameFunction = (animationFrameId) => {
|
||||||
|
actualCancelledAnimationFrameId = animationFrameId;
|
||||||
|
};
|
||||||
|
const context = new TestContext()
|
||||||
|
.withRequestAnimationFrameFunction(requestAnimationFrame)
|
||||||
|
.withCancelAnimationFrame(cancelAnimationFrame);
|
||||||
|
// act
|
||||||
|
const { resetNextFrame } = context.useAnimationFrameLimiter();
|
||||||
|
resetNextFrame(() => {}); // Nothing to cancel in first call
|
||||||
|
resetNextFrame(() => {});
|
||||||
|
// assert
|
||||||
|
expect(actualCancelledAnimationFrameId).to.equal(expectedCancelledAnimationFrameId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('cancelNextFrame', () => {
|
||||||
|
it('cancels the scheduled animation frame if one exists', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCancelledAnimationFrameId: AnimationFrameId = 5;
|
||||||
|
let actualCancelledAnimationFrameId: AnimationFrameId | undefined;
|
||||||
|
const requestAnimationFrame: RequestAnimationFrameFunction = () => {
|
||||||
|
return expectedCancelledAnimationFrameId;
|
||||||
|
};
|
||||||
|
const cancelAnimationFrame: CancelAnimationFrameFunction = (animationFrameId) => {
|
||||||
|
actualCancelledAnimationFrameId = animationFrameId;
|
||||||
|
};
|
||||||
|
const context = new TestContext()
|
||||||
|
.withRequestAnimationFrameFunction(requestAnimationFrame)
|
||||||
|
.withCancelAnimationFrame(cancelAnimationFrame);
|
||||||
|
// act
|
||||||
|
const { resetNextFrame, cancelNextFrame } = context.useAnimationFrameLimiter();
|
||||||
|
resetNextFrame(() => {}); // Schedule the initial one
|
||||||
|
cancelNextFrame();
|
||||||
|
// assert
|
||||||
|
expect(actualCancelledAnimationFrameId).to.equal(expectedCancelledAnimationFrameId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('automatically cancels the animation frame on cleanup', () => {
|
||||||
|
// arrange
|
||||||
|
let actualCancelledAnimationFrameId: AnimationFrameId | undefined;
|
||||||
|
let actualCleanupCallback: (() => void) | undefined;
|
||||||
|
const onTeardownCallback: RegisterTeardownCallbackFunction = (cleanupCallback) => {
|
||||||
|
actualCleanupCallback = cleanupCallback;
|
||||||
|
};
|
||||||
|
const expectedCancelledAnimationFrameId: AnimationFrameId = 5;
|
||||||
|
const requestAnimationFrame: RequestAnimationFrameFunction = () => {
|
||||||
|
return expectedCancelledAnimationFrameId;
|
||||||
|
};
|
||||||
|
const cancelAnimationFrame: CancelAnimationFrameFunction = (animationFrameId) => {
|
||||||
|
actualCancelledAnimationFrameId = animationFrameId;
|
||||||
|
};
|
||||||
|
const testContext = new TestContext()
|
||||||
|
.withOnTeardownCallback(onTeardownCallback)
|
||||||
|
.withRequestAnimationFrameFunction(requestAnimationFrame)
|
||||||
|
.withCancelAnimationFrame(cancelAnimationFrame);
|
||||||
|
// act
|
||||||
|
const { resetNextFrame } = testContext.useAnimationFrameLimiter();
|
||||||
|
resetNextFrame(() => {}); // Schedule the initial one
|
||||||
|
// assert
|
||||||
|
expectExists(actualCleanupCallback);
|
||||||
|
actualCleanupCallback();
|
||||||
|
expect(actualCancelledAnimationFrameId).to.equal(expectedCancelledAnimationFrameId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestContext {
|
||||||
|
private cancelAnimationFrame: CancelAnimationFrameFunction = () => {};
|
||||||
|
|
||||||
|
private requestAnimationFrameFunction: RequestAnimationFrameFunction = () => Math.random();
|
||||||
|
|
||||||
|
private onTeardownCallback: RegisterTeardownCallbackFunction = () => {};
|
||||||
|
|
||||||
|
public withRequestAnimationFrameFunction(
|
||||||
|
requestAnimationFrameFunction: RequestAnimationFrameFunction,
|
||||||
|
): this {
|
||||||
|
this.requestAnimationFrameFunction = requestAnimationFrameFunction;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCancelAnimationFrame(
|
||||||
|
cancelAnimationFrame: CancelAnimationFrameFunction,
|
||||||
|
): this {
|
||||||
|
this.cancelAnimationFrame = cancelAnimationFrame;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOnTeardownCallback(
|
||||||
|
registerCleanupCallback: RegisterTeardownCallbackFunction,
|
||||||
|
): this {
|
||||||
|
this.onTeardownCallback = registerCleanupCallback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public useAnimationFrameLimiter(): ReturnType<typeof useAnimationFrameLimiter> {
|
||||||
|
return useAnimationFrameLimiter(
|
||||||
|
this.cancelAnimationFrame,
|
||||||
|
this.requestAnimationFrameFunction,
|
||||||
|
this.onTeardownCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import { useResizeObserver, type LifecycleHookRegistration, type ObservedElementReference } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserver';
|
||||||
|
import { flushPromiseResolutionQueue } from '@tests/unit/shared/PromiseInspection';
|
||||||
|
import type { AnimationFrameLimiter } from '@/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter';
|
||||||
|
import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub';
|
||||||
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
|
||||||
|
describe('UseResizeObserver', () => {
|
||||||
|
it('registers observer once mounted', async () => {
|
||||||
|
// arrange
|
||||||
|
let registeredElement: Element | null = null;
|
||||||
|
const expectedElement = document.createElement('div');
|
||||||
|
const resizeObserverStub = createResizeObserverStub();
|
||||||
|
resizeObserverStub.observe = (element) => {
|
||||||
|
registeredElement = element;
|
||||||
|
};
|
||||||
|
// act
|
||||||
|
new TestContext()
|
||||||
|
.withObservedElementRef(shallowRef(expectedElement))
|
||||||
|
.withResizeObserver(resizeObserverStub)
|
||||||
|
.useResizeObserver();
|
||||||
|
await flushPromiseResolutionQueue();
|
||||||
|
// assert
|
||||||
|
expect(registeredElement).to.equal(expectedElement);
|
||||||
|
});
|
||||||
|
it('disposes observer once unmounted', async () => {
|
||||||
|
// arrange
|
||||||
|
let isObserverDisconnected = false;
|
||||||
|
const resizeObserverStub = createResizeObserverStub();
|
||||||
|
resizeObserverStub.disconnect = () => {
|
||||||
|
isObserverDisconnected = true;
|
||||||
|
};
|
||||||
|
let teardownCallback: (() => void) | undefined;
|
||||||
|
// act
|
||||||
|
new TestContext()
|
||||||
|
.withResizeObserver(resizeObserverStub)
|
||||||
|
.withOnTeardown((callback) => {
|
||||||
|
teardownCallback = callback;
|
||||||
|
})
|
||||||
|
.useResizeObserver();
|
||||||
|
await flushPromiseResolutionQueue();
|
||||||
|
expectExists(teardownCallback);
|
||||||
|
teardownCallback();
|
||||||
|
// assert
|
||||||
|
expect(isObserverDisconnected).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createResizeObserverStub(): ResizeObserver {
|
||||||
|
return {
|
||||||
|
disconnect: () => {},
|
||||||
|
observe: () => {},
|
||||||
|
unobserve: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrameLimiterStub(): AnimationFrameLimiter {
|
||||||
|
return {
|
||||||
|
cancelNextFrame: () => {},
|
||||||
|
resetNextFrame: (callback) => { callback(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestContext {
|
||||||
|
private resizeObserver: ResizeObserver = createResizeObserverStub();
|
||||||
|
|
||||||
|
private observedElementRef: ObservedElementReference = shallowRef(document.createElement('div'));
|
||||||
|
|
||||||
|
private onSetup: LifecycleHookRegistration = (callback) => { callback(); };
|
||||||
|
|
||||||
|
private onTeardown: LifecycleHookRegistration = () => { };
|
||||||
|
|
||||||
|
public withResizeObserver(resizeObserver: ResizeObserver): this {
|
||||||
|
this.resizeObserver = resizeObserver;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withObservedElementRef(observedElementRef: ObservedElementReference): this {
|
||||||
|
this.observedElementRef = observedElementRef;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOnSetup(onSetup: LifecycleHookRegistration): this {
|
||||||
|
this.onSetup = onSetup;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOnTeardown(onTeardown: LifecycleHookRegistration): this {
|
||||||
|
this.onTeardown = onTeardown;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public useResizeObserver() {
|
||||||
|
return useResizeObserver(
|
||||||
|
...this.buildParameters(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildParameters(): Parameters<typeof useResizeObserver> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
observedElementRef: this.observedElementRef,
|
||||||
|
throttleInMs: 50,
|
||||||
|
observeCallback: () => {},
|
||||||
|
},
|
||||||
|
() => ({
|
||||||
|
resizeObserverReady: Promise.resolve(() => this.resizeObserver),
|
||||||
|
}),
|
||||||
|
() => createFrameLimiterStub(),
|
||||||
|
new ThrottleStub()
|
||||||
|
.withImmediateExecution(true)
|
||||||
|
.func,
|
||||||
|
this.onSetup,
|
||||||
|
this.onTeardown,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { CallbackType, throttle } from '@/application/Common/Timing/Throttle';
|
import type { CallbackType, ThrottleFunction } from '@/application/Common/Timing/Throttle';
|
||||||
|
|
||||||
export class ThrottleStub {
|
export class ThrottleStub {
|
||||||
public readonly throttleInitializationCallArgs: Array<Parameters<typeof throttle>> = [];
|
public readonly throttleInitializationCallArgs: Array<Parameters<ThrottleFunction>> = [];
|
||||||
|
|
||||||
public readonly throttledFunctionCallArgs = new Array<readonly unknown[]>();
|
public readonly throttledFunctionCallArgs = new Array<readonly unknown[]>();
|
||||||
|
|
||||||
private executeImmediately: boolean = false;
|
private executeImmediately: boolean = false;
|
||||||
|
|
||||||
public func = (callback: CallbackType, waitInMs: number): ReturnType<typeof throttle> => {
|
public func = (callback: CallbackType, waitInMs: number): ReturnType<ThrottleFunction> => {
|
||||||
this.throttleInitializationCallArgs.push([callback, waitInMs]);
|
this.throttleInitializationCallArgs.push([callback, waitInMs]);
|
||||||
return (...args: readonly unknown[]) => {
|
return (...args: readonly unknown[]) => {
|
||||||
this.throttledFunctionCallArgs.push([...args]);
|
this.throttledFunctionCallArgs.push([...args]);
|
||||||
|
|||||||
Reference in New Issue
Block a user