// @vitest-environment jsdom import { renderHook, act } from "@testing-library/react"; import { observable, ObservableHint } from "@legendapp/state"; import type { OpaqueObject } from "@legendapp/state"; import { describe, it, expect, vi, afterEach } from "vitest"; import { useRef$ } from "@usels/core"; import { useDropZone } from "."; // --------------------------------------------------------------------------- // jsdom DragEvent polyfill // --------------------------------------------------------------------------- if (typeof window !== "undefined" && !window.DragEvent) { (globalThis as any).DragEvent = class DragEvent extends MouseEvent { dataTransfer: DataTransfer | null = null; constructor(type: string, init: DragEventInit = {}) { super(type, init); } }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createDiv() { return document.createElement("div"); } function createDragEvent(type: string, _files: File[] = [], mimeTypes: string[] = []): DragEvent { const event = new DragEvent(type, { bubbles: true, cancelable: true }); const items = mimeTypes.map((mime) => ({ type: mime, kind: "file" as const, })); const itemsProxy = new Proxy(items, { get(target, prop) { if (prop === "length") return target.length; if (prop === Symbol.iterator) return target[Symbol.iterator].bind(target); const idx = Number(prop); if (!isNaN(idx)) return target[idx]; return undefined; }, }) as unknown as DataTransferItemList; const filesProxy = new Proxy([] as File[], { get(target, prop) { if (prop === "length") return target.length; if (prop === Symbol.iterator) return target[Symbol.iterator].bind(target); const idx = Number(prop); if (!isNaN(idx)) return target[idx]; return undefined; }, }) as unknown as FileList; const dataTransfer = { items: itemsProxy, files: filesProxy, dropEffect: "none" as DataTransfer["dropEffect"], }; Object.defineProperty(event, "dataTransfer", { value: dataTransfer, writable: true, configurable: true, }); return event; } function fireDragEvent(target: EventTarget, event: DragEvent) { act(() => { target.dispatchEvent(event); }); } // The 4 drag event types managed by useDropZone const DRAG_EVENTS = ["dragenter", "dragleave", "dragover", "drop"] as const; // --------------------------------------------------------------------------- // useDropZone — element lifecycle // --------------------------------------------------------------------------- describe("useDropZone() — element lifecycle", () => { afterEach(() => { vi.restoreAllMocks(); }); describe("Ref$ target", () => { it("Ref$ null → element: all 4 drag listeners are registered", () => { const div = createDiv(); const addSpy = vi.spyOn(div, "addEventListener"); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); // Initially null — no listeners registered yet expect(addSpy).not.toHaveBeenCalled(); act(() => result.current.el$(div as any)); // All 4 drag event types must be registered const registeredEvents = addSpy.mock.calls.map((call) => call[0]); for (const event of DRAG_EVENTS) { expect(registeredEvents).toContain(event); } }); it("Ref$ element → null: all 4 drag listeners are removed", () => { const div = createDiv(); const removeSpy = vi.spyOn(div, "removeEventListener"); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); act(() => result.current.el$(div as any)); removeSpy.mockClear(); act(() => result.current.el$(null)); // All 4 drag event types must be removed const removedEvents = removeSpy.mock.calls.map((call) => call[0]); for (const event of DRAG_EVENTS) { expect(removedEvents).toContain(event); } }); it("addEventListener/removeEventListener call counts are symmetric after element → null", () => { const div = createDiv(); const addSpy = vi.spyOn(div, "addEventListener"); const removeSpy = vi.spyOn(div, "removeEventListener"); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); act(() => result.current.el$(div as any)); const addCount = addSpy.mock.calls.length; act(() => result.current.el$(null)); const removeCount = removeSpy.mock.calls.length; // Every addEventListener must have a matching removeEventListener expect(addCount).toBeGreaterThanOrEqual(4); expect(removeCount).toBe(addCount); }); }); describe("Observable target", () => { it("Observable target null → element: listeners are registered", () => { const div = createDiv(); const addSpy = vi.spyOn(div, "addEventListener"); const target$ = observable | null>(null); renderHook(() => useDropZone(target$ as any)); // Initially null — no listeners expect(addSpy).not.toHaveBeenCalled(); act(() => target$.set(ObservableHint.opaque(div))); const registeredEvents = addSpy.mock.calls.map((call) => call[0]); for (const event of DRAG_EVENTS) { expect(registeredEvents).toContain(event); } }); it("Observable target element → null: listeners are removed", () => { const div = createDiv(); const removeSpy = vi.spyOn(div, "removeEventListener"); const target$ = observable | null>(ObservableHint.opaque(div)); renderHook(() => useDropZone(target$ as any)); removeSpy.mockClear(); act(() => target$.set(null)); const removedEvents = removeSpy.mock.calls.map((call) => call[0]); for (const event of DRAG_EVENTS) { expect(removedEvents).toContain(event); } }); it("Observable target element → null → element: listeners re-registered", () => { const div = createDiv(); const addSpy = vi.spyOn(div, "addEventListener"); const target$ = observable | null>(ObservableHint.opaque(div)); renderHook(() => useDropZone(target$ as any)); addSpy.mockClear(); act(() => target$.set(null)); act(() => target$.set(ObservableHint.opaque(div))); // Re-registering after null → listeners should be added again const registeredEvents = addSpy.mock.calls.map((call) => call[0]); for (const event of DRAG_EVENTS) { expect(registeredEvents).toContain(event); } }); }); describe("full cycle (null → element → null → element)", () => { it("Ref$ full lifecycle: no leaked listeners after multiple cycles", () => { const div = createDiv(); const addSpy = vi.spyOn(div, "addEventListener"); const removeSpy = vi.spyOn(div, "removeEventListener"); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); // Run 3 null → element → null cycles for (let i = 0; i < 3; i++) { act(() => result.current.el$(div as any)); act(() => result.current.el$(null)); } // Total adds and removes must be symmetric — no leaked listeners expect(addSpy.mock.calls.length).toBe(removeSpy.mock.calls.length); }); it("drag counter resets when target element is removed (Ref$ → null)", () => { const div = createDiv(); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); act(() => result.current.el$(div as any)); // Simulate nested dragenter (counter = 2) fireDragEvent(div, createDragEvent("dragenter", [], [])); fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(result.current.dz.isOverDropZone$.get()).toBe(true); // Remove element — listeners are cleaned up (counter ref is part of hook instance, // it does not reset on element removal; isOverDropZone$ retains its last value) act(() => result.current.el$(null)); // Re-assign element to a new div to start fresh const div2 = createDiv(); act(() => result.current.el$(div2 as any)); // A dragleave on the new div (no prior dragenter on div2) should not set isOverDropZone$ // to false via counter (counter is still at 2 from div1 session, Math.max(0, 2-1) = 1) // The key behavior: listeners are working on the new element fireDragEvent(div2, createDragEvent("dragenter", [], [])); expect(result.current.dz.isOverDropZone$.get()).toBe(true); // Fire a drop to reset counter to 0 and isOverDropZone$ to false fireDragEvent(div2, createDragEvent("drop", [], [])); expect(result.current.dz.isOverDropZone$.get()).toBe(false); }); it("isOverDropZone$ resets to false when target is removed mid-drag", () => { const div = createDiv(); const { result } = renderHook(() => { const el$ = useRef$(); const dz = useDropZone(el$); return { el$, dz }; }); act(() => result.current.el$(div as any)); // Start a drag fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(result.current.dz.isOverDropZone$.get()).toBe(true); // Remove the element mid-drag act(() => result.current.el$(null)); // isOverDropZone$ remains at its last-set value since the observable persists // but no further drag events can fire (listeners removed) // The important check: the hook doesn't throw and listeners are cleaned up expect(() => result.current.dz.isOverDropZone$.get()).not.toThrow(); }); }); });