fix: Lazy unloading should not leave dangling connections (#2004)
- Closes #1992
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { LazyLoadController } from '../../src/components-lib/lazy-load-controller';
|
||||
import { LazyUnloadCondition } from '../../src/config/schema/common/media-actions';
|
||||
import {
|
||||
callIntersectionHandler,
|
||||
callVisibilityHandler,
|
||||
createLitElement,
|
||||
getMockIntersectionObserver,
|
||||
IntersectionObserverMock,
|
||||
} from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('LazyLoadController', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
vi.spyOn(global.document, 'removeEventListener');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be loaded by default', () => {
|
||||
const controller = new LazyLoadController(createLitElement());
|
||||
expect(controller.isLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be loaded by default when lazy load is set to true', () => {
|
||||
const controller = new LazyLoadController(createLitElement(), true);
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
});
|
||||
|
||||
it('should add controller to host', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new LazyLoadController(host);
|
||||
expect(host.addController).toBeCalledWith(controller);
|
||||
});
|
||||
|
||||
it('should remove controller from host', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new LazyLoadController(host);
|
||||
controller.removeController();
|
||||
expect(host.removeController).toBeCalledWith(controller);
|
||||
});
|
||||
|
||||
it('should remove handlers and listeners on destroy', () => {
|
||||
const controller = new LazyLoadController(createLitElement(), true, [
|
||||
'unselected',
|
||||
'hidden',
|
||||
]);
|
||||
controller.hostConnected();
|
||||
|
||||
const listener = vi.fn();
|
||||
controller.addListener(listener);
|
||||
|
||||
controller.destroy();
|
||||
|
||||
expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
|
||||
expect(global.document.removeEventListener).toBeCalledWith(
|
||||
'visibilitychange',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
|
||||
callVisibilityHandler(true);
|
||||
callIntersectionHandler(true);
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should lazy load', () => {
|
||||
it('should load when both visible and intersecting', () => {
|
||||
const controller = new LazyLoadController(createLitElement(), true);
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
|
||||
callVisibilityHandler(true);
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
expect(controller.isLoaded()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should lazy unload', () => {
|
||||
it('should unload on DOM disconnection', () => {
|
||||
const controller = new LazyLoadController(createLitElement());
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.isLoaded()).toBe(true);
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
|
||||
// Should also stop observing.
|
||||
expect(getMockIntersectionObserver()?.disconnect).toBeCalled();
|
||||
expect(global.document.removeEventListener).toBeCalledWith(
|
||||
'visibilitychange',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should lazy unload when not visible', () => {
|
||||
it.each([
|
||||
[[], true],
|
||||
[['unselected' as const], true],
|
||||
[['hidden' as const], false],
|
||||
[['unselected' as const, 'hidden' as const], false],
|
||||
])(
|
||||
'when unload conditions are: %s',
|
||||
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
|
||||
const controller = new LazyLoadController(
|
||||
createLitElement(),
|
||||
true,
|
||||
unloadConditions,
|
||||
);
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(true);
|
||||
callVisibilityHandler(true);
|
||||
expect(controller.isLoaded()).toBe(true);
|
||||
|
||||
callVisibilityHandler(false);
|
||||
expect(controller.isLoaded()).toBe(shouldBeLoaded);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should lazy unload when not intersecting', () => {
|
||||
it.each([
|
||||
[[], true],
|
||||
[['unselected' as const], false],
|
||||
[['hidden' as const], true],
|
||||
[['unselected' as const, 'hidden' as const], false],
|
||||
])(
|
||||
'when unload conditions are: %s',
|
||||
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
|
||||
const controller = new LazyLoadController(
|
||||
createLitElement(),
|
||||
true,
|
||||
unloadConditions,
|
||||
);
|
||||
controller.hostConnected();
|
||||
|
||||
callIntersectionHandler(true);
|
||||
callVisibilityHandler(true);
|
||||
expect(controller.isLoaded()).toBe(true);
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(controller.isLoaded()).toBe(shouldBeLoaded);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call listeners', () => {
|
||||
const listener = vi.fn();
|
||||
const controller = new LazyLoadController(createLitElement(), true, [
|
||||
'unselected',
|
||||
'hidden',
|
||||
]);
|
||||
controller.hostConnected();
|
||||
controller.addListener(listener);
|
||||
|
||||
expect(controller.isLoaded()).toBe(false);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
callVisibilityHandler(true);
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
|
||||
callIntersectionHandler(true);
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
expect(listener).toBeCalledTimes(3);
|
||||
|
||||
controller.removeListener(listener);
|
||||
|
||||
callIntersectionHandler(false);
|
||||
expect(listener).toBeCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
MutationObserverMock,
|
||||
callIntersectionHandler,
|
||||
callMutationHandler,
|
||||
callVisibilityHandler,
|
||||
createParent,
|
||||
flushPromises,
|
||||
} from '../test-utils';
|
||||
import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
|
||||
import { createTestSlideNodes } from '../utils/embla/test-utils';
|
||||
|
||||
const getPlayer = (
|
||||
element: HTMLElement,
|
||||
@@ -47,7 +48,7 @@ describe('MediaActionsController', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -455,11 +456,7 @@ describe('MediaActionsController', () => {
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).not.toBeCalled();
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
});
|
||||
await callVisibilityHandler();
|
||||
await callVisibilityHandler(true);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
@@ -502,11 +499,7 @@ describe('MediaActionsController', () => {
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
).not.toBeCalled();
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
writable: true,
|
||||
});
|
||||
await callVisibilityHandler();
|
||||
await callVisibilityHandler(false);
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
|
||||
+25
-3
@@ -383,15 +383,22 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const getMockIntersectionObserver = (n = 0): IntersectionObserver | null => {
|
||||
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
||||
if (mockResult.type !== 'return') {
|
||||
return null;
|
||||
}
|
||||
return mockResult.value;
|
||||
};
|
||||
|
||||
export const callIntersectionHandler = async (
|
||||
intersecting = true,
|
||||
n = 0,
|
||||
): Promise<void> => {
|
||||
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
||||
if (mockResult.type !== 'return') {
|
||||
const observer = getMockIntersectionObserver(n);
|
||||
if (!observer) {
|
||||
return;
|
||||
}
|
||||
const observer = mockResult.value;
|
||||
await (
|
||||
vi.mocked(IntersectionObserver).mock.calls[n][0] as
|
||||
| IntersectionObserverCallback
|
||||
@@ -422,6 +429,20 @@ export const callMutationHandler = async (n = 0): Promise<void> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const callVisibilityHandler = async (visible: boolean): Promise<void> => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: visible ? 'visible' : 'hidden',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mock = vi.mocked(global.document.addEventListener).mock;
|
||||
for (const [evt, cb] of mock.calls) {
|
||||
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
||||
await (cb as EventListener | ((_: unknown) => Promise<void>))(new Event('foo'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createSlotHost = (options?: {
|
||||
slot?: HTMLSlotElement;
|
||||
children?: HTMLElement[];
|
||||
@@ -453,6 +474,7 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen
|
||||
export const createLitElement = (): LitElement => {
|
||||
const element = document.createElement('div') as unknown as LitElement;
|
||||
element.addController = vi.fn();
|
||||
element.removeController = vi.fn();
|
||||
element.requestUpdate = vi.fn();
|
||||
|
||||
const promise: Promise<boolean> = new Promise((resolve) => {
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MEDIA_ACTION_NEGATIVE_CONDITIONS } from '../../../../../src/config/schema/common/media-actions';
|
||||
import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load';
|
||||
import {
|
||||
callEmblaHandler,
|
||||
callVisibilityHandler,
|
||||
createEmblaApiInstance,
|
||||
createTestEmblaOptionHandler,
|
||||
createTestSlideNodes,
|
||||
} from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('AutoLazyLoad', () => {
|
||||
it('should construct', () => {
|
||||
const plugin = AutoLazyLoad();
|
||||
expect(plugin.name).toBe('autoLazyLoad');
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: vi.fn(),
|
||||
lazyUnloadCallback: vi.fn(),
|
||||
});
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
plugin.destroy();
|
||||
|
||||
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
|
||||
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
|
||||
});
|
||||
|
||||
it('should do nothing without callbacks', () => {
|
||||
const plugin = AutoLazyLoad({
|
||||
// No callbacks provided.
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({ slideNodes: children });
|
||||
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
expect(emblaApi.on).not.toBeCalled();
|
||||
|
||||
plugin.destroy();
|
||||
expect(emblaApi.off).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should lazy load single slide on select', () => {
|
||||
const lazyLoadCallback = vi.fn();
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: lazyLoadCallback,
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({ slideNodes: children });
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
expect(emblaApi.on).toBeCalledWith('init', expect.anything());
|
||||
expect(emblaApi.on).toBeCalledWith('select', expect.anything());
|
||||
|
||||
callEmblaHandler(emblaApi, 'init');
|
||||
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
|
||||
|
||||
callEmblaHandler(emblaApi, 'select');
|
||||
|
||||
// The select call will not re-lazyload the same slide.
|
||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should lazy load multiple slides on select', () => {
|
||||
const lazyLoadCallback = vi.fn();
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: lazyLoadCallback,
|
||||
lazyLoadCount: 3,
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
slideNodes: children,
|
||||
selectedScrollSnap: 5,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
callEmblaHandler(emblaApi, 'select');
|
||||
for (let i = 3; i <= 8; ++i) {
|
||||
expect(lazyLoadCallback).toBeCalledWith(i, children[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should lazy unload on select', () => {
|
||||
const lazyUnloadCallback = vi.fn();
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: vi.fn(),
|
||||
lazyLoadCount: 3,
|
||||
lazyUnloadCallback: lazyUnloadCallback,
|
||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
selectedScrollSnap: 5,
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
callEmblaHandler(emblaApi, 'select');
|
||||
|
||||
// First call will not unload anything, since it was not lazy loaded.
|
||||
expect(lazyUnloadCallback).not.toBeCalled();
|
||||
|
||||
vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5);
|
||||
callEmblaHandler(emblaApi, 'select');
|
||||
|
||||
// Second call should lazy unload the previous slide.
|
||||
expect(lazyUnloadCallback).toBeCalledWith(5, children[5]);
|
||||
});
|
||||
|
||||
it('should lazy load on visibility', () => {
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
|
||||
const lazyLoadCallback = vi.fn();
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: lazyLoadCallback,
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
|
||||
});
|
||||
|
||||
it('should lazy unload on visibility', () => {
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
|
||||
const lazyUnloadCallback = vi.fn();
|
||||
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: vi.fn(),
|
||||
lazyUnloadCallback: lazyUnloadCallback,
|
||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
expect(lazyUnloadCallback).toBeCalledWith(0, children[0]);
|
||||
});
|
||||
|
||||
it('should not lazy unload on visibility without a callback', () => {
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
|
||||
const lazyLoadCallback = vi.fn();
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyLoadCallback: lazyLoadCallback,
|
||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
// No lazy unload callback.
|
||||
});
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
expect(lazyLoadCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not lazy load or unload on visibility when no callback provided', () => {
|
||||
vi.spyOn(global.document, 'addEventListener');
|
||||
|
||||
const plugin = AutoLazyLoad({
|
||||
lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
// No callbacks provided.
|
||||
});
|
||||
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
slideNodes: children,
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
});
|
||||
callVisibilityHandler();
|
||||
});
|
||||
});
|
||||
@@ -35,15 +35,6 @@ export const callEmblaHandler = (
|
||||
}
|
||||
};
|
||||
|
||||
export const callVisibilityHandler = async (): Promise<void> => {
|
||||
const mock = vi.mocked(global.document.addEventListener).mock;
|
||||
for (const [evt, cb] of mock.calls) {
|
||||
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
||||
await (cb as EventListener | ((_: unknown) => Promise<void>))(new Event('foo'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const callResizeHandler = (
|
||||
entries: {
|
||||
target: HTMLElement;
|
||||
|
||||
Reference in New Issue
Block a user