taskViewOptionsStability.test.tsx6.7 KBView on GitHub
import { renderHook } from '@testing-library/react';

/**
 * `useTaskListViewOptions` must return the SAME array references when nothing in the URL changed.
 *
 * This is not a performance nicety. The board, the list and the execution sidebar all read these
 * straight into `useMemo` dependency arrays, so an unstable identity makes every downstream memo
 * recompute on every render. On the board that reached `SortableContext`, which was handed a new
 * `items` array each pass and re-registered itself; dnd-kit re-measured its droppables,
 * `measureRect` called setState, and the resulting render produced yet another new array.
 *
 * The loop was invisible until a drag, because a drag is the only time dnd-kit's measuring
 * machinery runs to close the circuit — so it presented as "Maximum update depth exceeded, but
 * only while dragging a card".
 */

const params: Record<string, string | null> = {};

jest.mock('nuqs', () => ({
  useQueryState: (key=[redacted] => [params[key] ?? null, jest.fn()],
}));

// The hook now also reads the per-user board preference and writes it back. None of that is what
// these tests are about, so the settings round-trip is stubbed down to "nothing stored".
const mockStored: { value: { showDone?: boolean; collapseEmpty?: boolean } | undefined } = {
  value: undefined,
};

jest.mock('@/modules/userSettings/hooks/use-settings', () => ({
  useSettings: () => ({ data: { settings: { taskBoardOptions: mockStored.value } } }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    settings: {
      save: { mutationOptions: () => ({}) },
      get: { queryKey: () => ['settings', 'get'] },
    },
  }),
}));

jest.mock('@tanstack/react-query', () => ({
  useMutation: () => ({ mutate: jest.fn() }),
  useQueryClient: () => ({ invalidateQueries: jest.fn() }),
}));

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useTaskListViewOptions } = require('@/modules/userTasks/hooks/use-task-list-view-options');

const LIST_KEYS = ['filterGroups', 'filterChannels', 'filterStatuses', 'filterCrm', 'visibleProps'];

beforeEach(() => {
  for (const k of Object.keys(params)) delete params[k];
  mockStored.value = undefined;
});

describe('useTaskListViewOptions identity stability', () => {
  it('returns identical array references across a re-render with no URL change', () => {
    const { result, rerender } = renderHook(() => useTaskListViewOptions());
    const first = result.current;
    rerender();
    const second = result.current;

    for (const key of LIST_KEYS) {
      expect(second[key]).toBe(first[key]);
    }
  });

  it('holds that even for the EMPTY case, which is the default and the common one', () => {
    // The original bug returned a fresh `[]` every render, so a board with no filters applied —
    // i.e. almost every board — was the worst case rather than an untouched one.
    const { result, rerender } = renderHook(() => useTaskListViewOptions());
    expect(result.current.filterGroups).toEqual([]);
    const before = result.current.filterGroups;
    rerender();
    rerender();
    expect(result.current.filterGroups).toBe(before);
  });

  it('still returns a NEW reference once the underlying param actually changes', () => {
    const { result, rerender } = renderHook(() => useTaskListViewOptions());
    const before = result.current.filterGroups;

    params.filterGroups = 'group-a,group-b';
    rerender();

    expect(result.current.filterGroups).not.toBe(before);
    expect(result.current.filterGroups).toEqual(['group-a', 'group-b']);
  });

  it('parses a param the same way whichever render it lands on', () => {
    params.filterChannels = 'email,slack';
    const { result, rerender } = renderHook(() => useTaskListViewOptions());
    const first = result.current.filterChannels;
    rerender();
    expect(result.current.filterChannels).toBe(first);
    expect(result.current.filterChannels).toEqual(['email', 'slack']);
  });
});

/**
 * The two board switches both start OFF for a user who has never touched them, and that starting
 * point is the whole point of each.
 *
 * `collapseEmpty` off is what keeps an empty lane on the board: an empty lane is still a drop
 * target — filing the first task into a group is exactly when that group has nothing in it — and a
 * lane folded into the Hidden-columns rail is one you have to go find before you can drop on it.
 *
 * `showDone` off is what keeps the Done query off the wire for everyone who never asks for it.
 */
describe('useTaskListViewOptions board switches', () => {
  it('leaves empty columns on the board and the Done column off, with no URL params', () => {
    const { result } = renderHook(() => useTaskListViewOptions());
    expect(result.current.collapseEmpty).toBe(false);
    expect(result.current.showDone).toBe(false);
  });

  it('reads each one from its own param, and only from `1`', () => {
    params.collapseEmpty = '1';
    params.showDone = '1';
    const { result, rerender } = renderHook(() => useTaskListViewOptions());
    expect(result.current.collapseEmpty).toBe(true);
    expect(result.current.showDone).toBe(true);

    // Only '1' and '0' are answers. Anything else in a hand-edited URL is not one, so it falls
    // through to the stored preference — here, unset — rather than reading as on.
    params.collapseEmpty = 'true';
    params.showDone = '0';
    rerender();
    expect(result.current.collapseEmpty).toBe(false);
    expect(result.current.showDone).toBe(false);
  });
});

/**
 * The board's two switches are per-USER, not per-visit: an unparameterised `/tasks/kanban` opens
 * from the saved preference, and only an explicit param overrides it. The `'0'` case is the one
 * that matters — without it, turning a remembered switch OFF would read as "said nothing" and the
 * preference would turn it straight back on.
 */
describe('remembered board options', () => {
  it('falls back to the stored preference when the URL says nothing', () => {
    mockStored.value = { showDone: true, collapseEmpty: true };
    const { result } = renderHook(() => useTaskListViewOptions());

    expect(result.current.showDone).toBe(true);
    expect(result.current.collapseEmpty).toBe(true);
  });

  it('defaults both to off for a user who has never chosen', () => {
    const { result } = renderHook(() => useTaskListViewOptions());

    expect(result.current.showDone).toBe(false);
    expect(result.current.collapseEmpty).toBe(false);
  });

  it('lets an explicit param override the preference in BOTH directions', () => {
    mockStored.value = { showDone: true, collapseEmpty: false };
    params.showDone = '0';
    params.collapseEmpty = '1';
    const { result } = renderHook(() => useTaskListViewOptions());

    expect(result.current.showDone).toBe(false);
    expect(result.current.collapseEmpty).toBe(true);
  });
});