Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/__tests__/CompareResults/ResultsTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1248,3 +1248,68 @@ describe('Results Table for MannWhitneyResultsItem for mann-whitney-u testVersio
});
});
});

// Filters/sort are remembered in cookies, but an "initialized" URL must be
// reproducible regardless of the viewer's cookies. (Kept at the end of the file
// so it doesn't shift the useId counter that earlier snapshot tests capture.)
describe('cookie persistence vs. shareable URLs', () => {
it('seeds filters from cookies and marks the URL initialized on a fresh URL', async () => {
document.cookie = 'perfcompare_filter_status=regression; path=/';
const { testCompareData } = getTestData();
setupAndRender(testCompareData, 'test_version=student-t');

await screen.findByText('a11yr');

// The remembered cookie is applied to the view...
expect(summarizeVisibleRows()).toEqual([
'a11yr dhtml.html spam opt e10s fission stylo webrender',
' - Linux 18.04, Regression, 1.85 %, Medium',
]);
// ...and materialised into the URL, which is now marked initialized so the
// link reproduces this exact view for anyone.
expect(summarizeTableFiltersFromUrl()).toEqual({ status: ['regression'] });
expect(new URLSearchParams(window.location.search).get('initialized')).toBe(
'1',
);
});

it('ignores cookies when the URL is already initialized', async () => {
// A different viewer's cookie must not change what an initialized (shared)
// URL displays.
document.cookie = 'perfcompare_filter_status=regression; path=/';
const { testCompareData } = getTestData();
setupAndRender(testCompareData, 'test_version=student-t&initialized=1');

await screen.findByText('a11yr');

// Cookie is ignored: every status stays visible.
expect(summarizeVisibleRows()).toEqual([
'a11yr dhtml.html spam opt e10s fission stylo webrender',
' - Linux 18.04, Regression, 1.85 %, Medium',
' - macOS 10.15, Improvement, 1.08 %, Low',
' - Windows 10, -, -24 %, -',
' - Windows 10, -, -2.4 %, High',
]);
// ...and the cookie is not written into the URL.
expect(summarizeTableFiltersFromUrl()).toEqual({});
});

it('keeps the initialized marker after toggling a filter', async () => {
const { testCompareData } = getTestData();
setupAndRender(testCompareData, 'test_version=student-t');

await screen.findByText('a11yr');

const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
});
await clickMenuItem(user, 'Status', /No changes/);

expect(summarizeTableFiltersFromUrl()).toEqual({
status: ['improvement', 'regression'],
});
expect(new URLSearchParams(window.location.search).get('initialized')).toBe(
'1',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down Expand Up @@ -108,7 +108,7 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
style="width: 1px; height: 1px; top: -0.5px; left: -0.5px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving MuiTouchRipple-childPulsate"
class="MuiTouchRipple-child MuiTouchRipple-childPulsate"
/>
</span>
<span
Expand Down Expand Up @@ -205,7 +205,7 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down Expand Up @@ -250,7 +250,7 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down Expand Up @@ -1929,7 +1929,7 @@ exports[`SubtestsResultsView Component Tests table sorting can sort the table an
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down Expand Up @@ -1982,7 +1982,7 @@ exports[`SubtestsResultsView Component Tests table sorting can sort the table an
style="width: 1px; height: 1px; top: -0.5px; left: -0.5px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving MuiTouchRipple-childPulsate"
class="MuiTouchRipple-child MuiTouchRipple-childPulsate"
/>
</span>
<span
Expand Down Expand Up @@ -2079,7 +2079,7 @@ exports[`SubtestsResultsView Component Tests table sorting can sort the table an
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down Expand Up @@ -2168,7 +2168,7 @@ exports[`SubtestsResultsView Component Tests table sorting can sort the table an
style="width: 2.8284271247461903px; height: 2.8284271247461903px; top: -1.4142135623730951px; left: -1.4142135623730951px;"
>
<span
class="MuiTouchRipple-child MuiTouchRipple-childLeaving"
class="MuiTouchRipple-child"
/>
</span>
<span
Expand Down
5 changes: 5 additions & 0 deletions src/components/CompareResults/ResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ResultsControls from './ResultsControls';
import TableContent from './TableContent';
import TableHeader from './TableHeader';
import { MANN_WHITNEY_U } from '../../common/constants';
import useInitializeTableStateFromCookies from '../../hooks/useInitializeTableStateFromCookies';
import useRawSearchParams from '../../hooks/useRawSearchParams';
import useTableFilters from '../../hooks/useTableFilters';
import useTableSort from '../../hooks/useTableSort';
Expand Down Expand Up @@ -40,6 +41,10 @@ export default function ResultsTable() {
testVersion ?? MANN_WHITNEY_U,
);

// On a fresh (uninitialized) URL, seed filter/sort from cookies into the URL
// and mark it initialized, so shared links reproduce the same view.
useInitializeTableStateFromCookies(columnsConfig);

// This is our custom hook that manages table filters
// and provides methods for clearing and toggling them.
const { tableFilters, onClearFilter, onToggleFilter } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SubtestsTableContent from './SubtestsTableContent';
import NoResultsFound from '.././NoResultsFound';
import TableHeader from '.././TableHeader';
import { STUDENT_T } from '../../../common/constants';
import useInitializeTableStateFromCookies from '../../../hooks/useInitializeTableStateFromCookies';
import useTableFilters, { filterResults } from '../../../hooks/useTableFilters';
import useTableSort, { sortResults } from '../../../hooks/useTableSort';
import type { CombinedResultsItemType } from '../../../types/state';
Expand Down Expand Up @@ -83,6 +84,11 @@ function SubtestsResultsTable({
true,
testVersion ?? STUDENT_T,
);

// On a fresh (uninitialized) URL, seed filter/sort from cookies into the URL
// and mark it initialized, so shared links reproduce the same view.
useInitializeTableStateFromCookies(columnsConfiguration);

// This is our custom hook that manages table filters
// and provides methods for clearing and toggling them.
const { tableFilters, onClearFilter, onToggleFilter } =
Expand Down
74 changes: 74 additions & 0 deletions src/hooks/useInitializeTableStateFromCookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useEffect } from 'react';

import useRawSearchParams from './useRawSearchParams';
import type {
CompareResultsTableConfig,
CompareMannWhitneyResultsTableConfig,
} from '../types/types';
import { getCookie } from '../utils/cookies';
import {
INITIALIZED_PARAM,
SORT_PARAM,
SORT_COOKIE,
filterParam,
filterCookie,
isTableStateInitialized,
currentUrlParams,
} from '../utils/tableStatePersistence';

// On the first load of an *uninitialized* results URL (e.g. arriving from the
// search form), copy the remembered filter/sort cookies into the URL and stamp
// it as initialized. From then on the URL fully describes the view, so sharing
// it reproduces the same result for everyone — the recipient's cookies are
// ignored because the URL is initialized (see useTableFilters/useTableSort).
//
// This runs exactly once and only mutates the URL via history.replaceState
// (through useRawSearchParams), so it never triggers a re-render or a loader
// refetch. It intentionally has no effect on an already-initialized URL.
const useInitializeTableStateFromCookies = (
columnsConfiguration:
| CompareResultsTableConfig
| CompareMannWhitneyResultsTableConfig,
) => {
const [, updateRawSearchParams] = useRawSearchParams();

useEffect(() => {
if (isTableStateInitialized(window.location.search)) {
return;
}

const params = currentUrlParams();

// Only seed a value from a cookie when the URL doesn't already specify it,
// so an explicit URL param always wins over the cookie.
for (const column of columnsConfiguration) {
if (!('filter' in column)) {
continue;
}
const param = filterParam(column.key);
if (!params.has(param)) {
const cookieValue = getCookie(filterCookie(column.key));
if (cookieValue) {
params.set(param, cookieValue);
}
}
}

if (!params.has(SORT_PARAM)) {
const sortCookie = getCookie(SORT_COOKIE);
if (sortCookie) {
params.set(SORT_PARAM, sortCookie);
}
}

// Stamp the marker even when there were no cookies, so a filter-free view
// is still "initialized" and can't pick up cookies on this or another
// browser later.
params.set(INITIALIZED_PARAM, '1');
updateRawSearchParams(params);
// Mount-only: the URL is materialised once and the data hooks have already
// seeded their state from the same cookies during the first render.
}, []);
};

export default useInitializeTableStateFromCookies;
32 changes: 22 additions & 10 deletions src/hooks/useTableFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import type {
CompareMannWhitneyResultsTableColumn,
} from '../types/types';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
import {
filterParam,
filterCookie,
isTableStateInitialized,
currentUrlParams,
} from '../utils/tableStatePersistence';

// This hook handles the state that handles table filtering, and also takes care
// of handling the URL parameters that mirror this state.
Expand Down Expand Up @@ -65,6 +71,10 @@ const useTableFilters = (

// This function collects the table filters from the search params. It will
// only be called once at mount time.
// Cookies are only consulted for an uninitialized URL; an initialized URL is
// the single source of truth so a shared link reproduces the same view.
const initialized = isTableStateInitialized(window.location.search);

const getInitialTableFilters = () => {
const result: Map<string, Set<string>> = new Map();
for (const columnConfiguration of columnsConfiguration) {
Expand All @@ -75,8 +85,8 @@ const useTableFilters = (
const { key: columnKey, possibleValues } = columnConfiguration;

const paramValue =
rawSearchParams.get('filter_' + columnKey) ??
getCookie('perfcompare_filter_' + columnKey);
rawSearchParams.get(filterParam(columnKey)) ??
(initialized ? null : getCookie(filterCookie(columnKey)));
if (paramValue) {
const configuredValuesSet = new Set(
paramValue.split(',').map((item) => item.trim()),
Expand All @@ -101,9 +111,10 @@ const useTableFilters = (
const [tableFilters, setTableFilters] = useState(getInitialTableFilters);

const onClearFilter = (columnId: string) => {
rawSearchParams.delete(`filter_${columnId}`);
updateRawSearchParams(rawSearchParams);
deleteCookie(`perfcompare_filter_${columnId}`);
const params = currentUrlParams();
params.delete(filterParam(columnId));
updateRawSearchParams(params);
deleteCookie(filterCookie(columnId));

setTableFilters((oldFilters) => {
const newFilters = new Map(oldFilters);
Expand All @@ -123,14 +134,15 @@ const useTableFilters = (
return;
}

const params = currentUrlParams();
if (filters.size < columnConfiguration.possibleValues.length) {
rawSearchParams.set(`filter_${columnId}`, [...filters].join(','));
setCookie(`perfcompare_filter_${columnId}`, [...filters].join(','));
params.set(filterParam(columnId), [...filters].join(','));
setCookie(filterCookie(columnId), [...filters].join(','));
} else {
rawSearchParams.delete(`filter_${columnId}`);
deleteCookie(`perfcompare_filter_${columnId}`);
params.delete(filterParam(columnId));
deleteCookie(filterCookie(columnId));
}
updateRawSearchParams(rawSearchParams);
updateRawSearchParams(params);

setTableFilters((oldFilters) => {
const newFilters = new Map(oldFilters);
Expand Down
24 changes: 18 additions & 6 deletions src/hooks/useTableSort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type {
SortFunc,
} from '../types/types';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
import {
SORT_PARAM,
SORT_COOKIE,
isTableStateInitialized,
currentUrlParams,
} from '../utils/tableStatePersistence';

// This hook handles the state that handles table sorting, and also takes care
// of handling the URL parameters that mirror this state.
Expand All @@ -25,8 +31,13 @@ const useTableSort = (
// This is our custom hook that updates the search params without a rerender.
const [rawSearchParams, updateRawSearchParams] = useRawSearchParams();

// Cookies are only consulted for an uninitialized URL; an initialized URL is
// the single source of truth so a shared link reproduces the same view.
const initialized = isTableStateInitialized(window.location.search);
const sortFromUrl =
rawSearchParams.get('sort') ?? getCookie('perfcompare_sort') ?? '';
rawSearchParams.get(SORT_PARAM) ??
(initialized ? null : getCookie(SORT_COOKIE)) ??
'';
const [columnId, direction] = useMemo(() => {
const [columnId, direction] = sortFromUrl.split('|');
if (!columnId) {
Expand Down Expand Up @@ -57,18 +68,19 @@ const useTableSort = (
columnId: string,
newSortDirection: 'asc' | 'desc' | null,
) => {
const params = currentUrlParams();
if (newSortDirection === null) {
setSortColumn(null);
setSortDirection(null);
rawSearchParams.delete('sort');
deleteCookie('perfcompare_sort');
params.delete(SORT_PARAM);
deleteCookie(SORT_COOKIE);
} else {
setSortColumn(columnId);
setSortDirection(newSortDirection);
rawSearchParams.set('sort', columnId + '|' + newSortDirection);
setCookie('perfcompare_sort', columnId + '|' + newSortDirection);
params.set(SORT_PARAM, columnId + '|' + newSortDirection);
setCookie(SORT_COOKIE, columnId + '|' + newSortDirection);
}
updateRawSearchParams(rawSearchParams);
updateRawSearchParams(params);
};

return { sortDirection, sortColumn, onToggleSort };
Expand Down
Loading