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);
});
});