diff --git a/packages/core/src/plugin/Context.test.tsx b/packages/core/src/plugin/Context.test.tsx index 549c47a..c9939a8 100644 --- a/packages/core/src/plugin/Context.test.tsx +++ b/packages/core/src/plugin/Context.test.tsx @@ -7,93 +7,15 @@ import { PluginArgsProvider, usePluginArgs, } from "./Context"; -import type { SeizenTableInstance } from "../table/useSeizenTable"; import type { ColumnFiltersState, SortingState, PaginationState, } from "@tanstack/react-table"; - -// ============================================================================= -// Test Helpers -// ============================================================================= - -interface TestRow { - id: number; - name: string; -} - -/** - * Create a mock SeizenTableInstance for testing - */ -function createMockTableInstance( - overrides: Partial> = {} -): SeizenTableInstance { - const eventListeners = new Map void>>(); - - const mockEventBus = { - emit: vi.fn((event: string, payload: unknown) => { - const listeners = eventListeners.get(event); - if (listeners) { - listeners.forEach((cb) => cb(payload)); - } - }), - subscribe: vi.fn((event: string, callback: (payload: unknown) => void) => { - if (!eventListeners.has(event)) { - eventListeners.set(event, new Set()); - } - eventListeners.get(event)!.add(callback); - return () => { - eventListeners.get(event)?.delete(callback); - }; - }), - }; - - const mockPlugin = { - open: vi.fn(), - close: vi.fn(), - isOpen: vi.fn(() => false), - getActiveId: vi.fn(() => null), - setActive: vi.fn(), - toggle: vi.fn(), - _state: { id: null, args: undefined }, - }; - - return { - getData: () => [ - { id: 1, name: "Alice" }, - { id: 2, name: "Bob" }, - ], - getColumns: () => [ - { accessorKey: "id", header: "ID" }, - { accessorKey: "name", header: "Name" }, - ], - getSelectedRows: () => [], - setSelectedRows: vi.fn(), - clearSelection: vi.fn(), - getFilterState: () => [], - setFilter: vi.fn(), - getGlobalFilter: () => "", - setGlobalFilter: vi.fn(), - getSortingState: () => [], - setSorting: vi.fn(), - getPaginationState: () => ({ pageIndex: 0, pageSize: 10 }), - setPageIndex: vi.fn(), - setPageSize: vi.fn(), - getColumnVisibility: () => ({}), - setColumnVisibility: vi.fn(), - toggleColumnVisibility: vi.fn(), - getColumnOrder: () => [], - setColumnOrder: vi.fn(), - moveColumn: vi.fn(), - plugins: [], - plugin: mockPlugin as any, - eventBus: mockEventBus as any, - remote: false, - _tanstackTable: {} as any, - ...overrides, - }; -} +import { + createMockTableInstance, + type TestRow, +} from "../../tests/utils/mockTable"; // ============================================================================= // PluginContextProvider Tests diff --git a/packages/core/src/plugin/useEventBus.test.ts b/packages/core/src/plugin/useEventBus.test.ts index de3ff25..484c653 100644 --- a/packages/core/src/plugin/useEventBus.test.ts +++ b/packages/core/src/plugin/useEventBus.test.ts @@ -255,6 +255,16 @@ describe("useEventBus", () => { expect(result.current.subscribe).toBe(subscribe1); }); + it("eventBus object itself should be stable across renders", () => { + const { result, rerender } = renderHook(() => useEventBus()); + + const eventBus1 = result.current; + + rerender(); + + expect(result.current).toBe(eventBus1); + }); + it("should maintain subscribers across re-renders", () => { const { result, rerender } = renderHook(() => useEventBus()); const callback = vi.fn(); diff --git a/packages/core/src/plugin/useEventBus.ts b/packages/core/src/plugin/useEventBus.ts index 6de5973..353a09c 100644 --- a/packages/core/src/plugin/useEventBus.ts +++ b/packages/core/src/plugin/useEventBus.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import type { ColumnFiltersState, PaginationState, @@ -171,18 +171,21 @@ export function useEventBus() { [] ); - return { - /** - * Emit an event to all subscribers - */ - emit, - - /** - * Subscribe to an event - * @returns Unsubscribe function - */ - subscribe, - }; + return useMemo( + () => ({ + /** + * Emit an event to all subscribers + */ + emit, + + /** + * Subscribe to an event + * @returns Unsubscribe function + */ + subscribe, + }), + [emit, subscribe] + ); } export type EventBus = ReturnType; diff --git a/packages/core/src/table/useSeizenTableEvent.test.ts b/packages/core/src/table/useSeizenTableEvent.test.ts new file mode 100644 index 0000000..745c303 --- /dev/null +++ b/packages/core/src/table/useSeizenTableEvent.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useSeizenTableEvent } from "./useSeizenTableEvent"; +import type { SeizenTableInstance } from "./useSeizenTable"; +import type { EventBus } from "../plugin/useEventBus"; +import type { SortingState, PaginationState } from "@tanstack/react-table"; +import { + createMockEventBus, + createMockTableInstance, + type TestRow, +} from "../../tests/utils/mockTable"; + +// ============================================================================= +// useSeizenTableEvent Hook Tests +// ============================================================================= + +describe("useSeizenTableEvent", () => { + let mockEventBus: EventBus; + let mockTable: SeizenTableInstance; + + beforeEach(() => { + mockEventBus = createMockEventBus(); + mockTable = createMockTableInstance({ eventBus: mockEventBus }); + }); + + describe("subscription", () => { + it("should subscribe to events on mount", () => { + const callback = vi.fn(); + + renderHook(() => + useSeizenTableEvent(mockTable, "sorting-change", callback) + ); + + expect(mockEventBus.subscribe).toHaveBeenCalledWith( + "sorting-change", + expect.any(Function) + ); + }); + + it("should call callback when event is emitted", async () => { + const callback = vi.fn(); + const sorting: SortingState = [{ id: "name", desc: false }]; + + renderHook(() => + useSeizenTableEvent(mockTable, "sorting-change", callback) + ); + + act(() => { + mockEventBus.emit("sorting-change", sorting); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(sorting); + }); + }); + + it("should call callback for pagination-change event", async () => { + const callback = vi.fn(); + const pagination: PaginationState = { pageIndex: 2, pageSize: 20 }; + + renderHook(() => + useSeizenTableEvent(mockTable, "pagination-change", callback) + ); + + act(() => { + mockEventBus.emit("pagination-change", pagination); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(pagination); + }); + }); + + it("should not call callback for different events", () => { + const callback = vi.fn(); + + renderHook(() => + useSeizenTableEvent(mockTable, "sorting-change", callback) + ); + + act(() => { + mockEventBus.emit("pagination-change", { pageIndex: 0, pageSize: 10 }); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("unsubscription", () => { + it("should unsubscribe on unmount", () => { + const callback = vi.fn(); + + const { unmount } = renderHook(() => + useSeizenTableEvent(mockTable, "sorting-change", callback) + ); + + unmount(); + + // After unmount, emitting should not call the callback + act(() => { + mockEventBus.emit("sorting-change", [{ id: "name", desc: true }]); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("callback updates", () => { + it("should use latest callback without re-subscribing", async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const { rerender } = renderHook( + ({ callback }) => + useSeizenTableEvent(mockTable, "sorting-change", callback), + { initialProps: { callback: callback1 } } + ); + + // First emit + act(() => { + mockEventBus.emit("sorting-change", [{ id: "name", desc: false }]); + }); + + await waitFor(() => { + expect(callback1).toHaveBeenCalledTimes(1); + }); + + // Update callback + rerender({ callback: callback2 }); + + // Second emit should use new callback + act(() => { + mockEventBus.emit("sorting-change", [{ id: "name", desc: true }]); + }); + + await waitFor(() => { + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledTimes(1); // Still 1, not called again + }); + + // Should only subscribe once (not re-subscribe on callback change) + expect(mockEventBus.subscribe).toHaveBeenCalledTimes(1); + }); + }); + + describe("multiple subscriptions", () => { + it("should handle multiple useSeizenTableEvent calls for different events", async () => { + const sortingCallback = vi.fn(); + const paginationCallback = vi.fn(); + + renderHook(() => { + useSeizenTableEvent(mockTable, "sorting-change", sortingCallback); + useSeizenTableEvent(mockTable, "pagination-change", paginationCallback); + }); + + act(() => { + mockEventBus.emit("sorting-change", [{ id: "name", desc: false }]); + mockEventBus.emit("pagination-change", { pageIndex: 1, pageSize: 20 }); + }); + + await waitFor(() => { + expect(sortingCallback).toHaveBeenCalledWith([ + { id: "name", desc: false }, + ]); + expect(paginationCallback).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: 20, + }); + }); + }); + }); + + describe("event bus stability", () => { + it("should not re-subscribe when table reference changes but eventBus is same", () => { + const callback = vi.fn(); + + const { rerender } = renderHook( + ({ table }) => useSeizenTableEvent(table, "sorting-change", callback), + { initialProps: { table: mockTable } } + ); + + // Create new table with same eventBus + const newTable = createMockTableInstance({ eventBus: mockEventBus }); + + rerender({ table: newTable }); + + // Should only have subscribed once since eventBus is the same + expect(mockEventBus.subscribe).toHaveBeenCalledTimes(1); + }); + + it("should re-subscribe when eventBus changes", () => { + const callback = vi.fn(); + const newEventBus = createMockEventBus(); + + const { rerender } = renderHook( + ({ table }) => useSeizenTableEvent(table, "sorting-change", callback), + { initialProps: { table: mockTable } } + ); + + // Create new table with different eventBus + const newTable = createMockTableInstance({ eventBus: newEventBus }); + + rerender({ table: newTable }); + + // Should have subscribed to both event buses + expect(mockEventBus.subscribe).toHaveBeenCalledTimes(1); + expect(newEventBus.subscribe).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/core/tests/utils/mockTable.ts b/packages/core/tests/utils/mockTable.ts new file mode 100644 index 0000000..dbbba51 --- /dev/null +++ b/packages/core/tests/utils/mockTable.ts @@ -0,0 +1,101 @@ +import { vi } from "vitest"; +import type { SeizenTableInstance } from "../../src/table/useSeizenTable"; +import type { EventBus } from "../../src/plugin/useEventBus"; + +// ============================================================================= +// Test Row Type +// ============================================================================= + +export interface TestRow { + id: number; + name: string; +} + +// ============================================================================= +// Mock EventBus +// ============================================================================= + +/** + * Create a mock EventBus for testing. + * This mock maintains a real listener registry so emit() actually calls subscribers. + */ +export function createMockEventBus(): EventBus { + const listeners = new Map void>>(); + + return { + emit: vi.fn((event: string, payload: unknown) => { + const eventListeners = listeners.get(event); + if (eventListeners) { + eventListeners.forEach((callback) => callback(payload)); + } + }) as EventBus["emit"], + subscribe: vi.fn((event: string, callback: (payload: unknown) => void) => { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event)!.add(callback); + + return () => { + listeners.get(event)?.delete(callback); + }; + }) as EventBus["subscribe"], + }; +} + +// ============================================================================= +// Mock Table Instance +// ============================================================================= + +/** + * Create a mock SeizenTableInstance for testing. + * Allows overriding specific properties while providing sensible defaults. + */ +export function createMockTableInstance( + overrides: Partial> = {} +): SeizenTableInstance { + const eventBus = overrides.eventBus ?? createMockEventBus(); + + const mockPlugin = { + open: vi.fn(), + close: vi.fn(), + isOpen: vi.fn(() => false), + getActiveId: vi.fn(() => null), + setActive: vi.fn(), + _state: { id: null, args: undefined }, + }; + + return { + getData: () => [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ], + getColumns: () => [ + { accessorKey: "id", header: "ID" }, + { accessorKey: "name", header: "Name" }, + ], + getSelectedRows: () => [], + setSelectedRows: vi.fn(), + clearSelection: vi.fn(), + getFilterState: () => [], + setFilter: vi.fn(), + getGlobalFilter: () => "", + setGlobalFilter: vi.fn(), + getSortingState: () => [], + setSorting: vi.fn(), + getPaginationState: () => ({ pageIndex: 0, pageSize: 10 }), + setPageIndex: vi.fn(), + setPageSize: vi.fn(), + getColumnVisibility: () => ({}), + setColumnVisibility: vi.fn(), + toggleColumnVisibility: vi.fn(), + getColumnOrder: () => [], + setColumnOrder: vi.fn(), + moveColumn: vi.fn(), + plugins: [], + plugin: mockPlugin as SeizenTableInstance["plugin"], + eventBus: eventBus as SeizenTableInstance["eventBus"], + remote: false, + _tanstackTable: {} as SeizenTableInstance["_tanstackTable"], + ...overrides, + }; +}