Skip to content

Commit b742661

Browse files
authored
feat: make workspace search bar remember text (#9759)
* minor: Add useEffectEvent polyfill * chore: update filter to have better callback support * docs: Clean up comments * fix: add localStorage to useWorkspacesFilter * refactor: Centralize stable useSearchParams * refactor: clean up filter to be fully pure on mount * chore: add tests for useEffectEvent * wip: commit progress for searchbar fix * chore: clean up WorkspacesPage * fix: add logic for syncing queries with search params * chore: Rename initialValue to fallbackFilter * chore: Remove todo comment * refactor: update code to use useEffectEvent * docs: clean up comments for clarity * fix: update url check to use regex
1 parent 92a90eb commit b742661

File tree

5 files changed

+202
-33
lines changed

5 files changed

+202
-33
lines changed

site/e2e/global.setup.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ test("create first user", async ({ page }) => {
1212
await page.getByTestId("trial").click();
1313
await page.getByTestId("create").click();
1414

15-
await expect(page).toHaveURL("/workspaces");
16-
15+
await expect(page).toHaveURL(/\/workspaces.*/);
1716
await page.context().storageState({ path: STORAGE_STATE });
1817
});

site/src/components/Filter/filter.tsx

+51-23
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import MenuList from "@mui/material/MenuList";
2424
import { Loader } from "components/Loader/Loader";
2525
import Divider from "@mui/material/Divider";
2626
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
27+
2728
import { useDebouncedFunction } from "hooks/debounce";
29+
import { useEffectEvent } from "hooks/hookPolyfills";
2830

2931
export type PresetFilter = {
3032
name: string;
@@ -33,45 +35,71 @@ export type PresetFilter = {
3335

3436
type FilterValues = Record<string, string | undefined>;
3537

38+
type UseFilterConfig = {
39+
/**
40+
* The fallback value to use in the event that no filter params can be parsed
41+
* from the search params object. This value is allowed to change on
42+
* re-renders.
43+
*/
44+
fallbackFilter?: string;
45+
searchParamsResult: ReturnType<typeof useSearchParams>;
46+
onUpdate?: (newValue: string) => void;
47+
};
48+
49+
const useFilterParamsKey = "filter";
50+
3651
export const useFilter = ({
37-
initialValue = "",
38-
onUpdate,
52+
fallbackFilter = "",
3953
searchParamsResult,
40-
}: {
41-
initialValue?: string;
42-
searchParamsResult: ReturnType<typeof useSearchParams>;
43-
onUpdate?: () => void;
44-
}) => {
54+
onUpdate,
55+
}: UseFilterConfig) => {
4556
const [searchParams, setSearchParams] = searchParamsResult;
46-
const query = searchParams.get("filter") ?? initialValue;
47-
const values = parseFilterQuery(query);
48-
49-
const update = (values: string | FilterValues) => {
50-
if (typeof values === "string") {
51-
searchParams.set("filter", values);
52-
} else {
53-
searchParams.set("filter", stringifyFilter(values));
54-
}
57+
const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter;
58+
59+
// Stabilizing reference to setSearchParams from one central spot, just to be
60+
// on the extra careful side; don't want effects over-running. You would think
61+
// this would be overkill, but setSearchParams isn't stable out of the box
62+
const stableSetSearchParams = useEffectEvent(setSearchParams);
63+
64+
// Keep params synced with query, even as query changes from outside sources
65+
useEffect(() => {
66+
stableSetSearchParams((currentParams) => {
67+
const currentQuery = currentParams.get(useFilterParamsKey);
68+
69+
if (query === "") {
70+
currentParams.delete(useFilterParamsKey);
71+
} else if (currentQuery !== query) {
72+
currentParams.set(useFilterParamsKey, query);
73+
}
74+
75+
return currentParams;
76+
});
77+
}, [stableSetSearchParams, query]);
78+
79+
const update = (newValues: string | FilterValues) => {
80+
const serialized =
81+
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
82+
83+
searchParams.set(useFilterParamsKey, serialized);
5584
setSearchParams(searchParams);
56-
if (onUpdate) {
57-
onUpdate();
85+
86+
if (onUpdate !== undefined) {
87+
onUpdate(serialized);
5888
}
5989
};
6090

6191
const { debounced: debounceUpdate, cancelDebounce } = useDebouncedFunction(
62-
(values: string | FilterValues) => update(values),
92+
update,
6393
500,
6494
);
6595

66-
const used = query !== "" && query !== initialValue;
67-
6896
return {
6997
query,
7098
update,
7199
debounceUpdate,
72100
cancelDebounce,
73-
values,
74-
used,
101+
values: parseFilterQuery(query),
102+
used: query !== "" && query !== fallbackFilter,
75103
};
76104
};
77105

site/src/hooks/hookPolyfills.test.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useEffectEvent } from "./hookPolyfills";
3+
4+
function renderEffectEvent<TArgs extends unknown[], TReturn = unknown>(
5+
callbackArg: (...args: TArgs) => TReturn,
6+
) {
7+
return renderHook(
8+
({ callback }: { callback: typeof callbackArg }) => {
9+
return useEffectEvent(callback);
10+
},
11+
{
12+
initialProps: { callback: callbackArg },
13+
},
14+
);
15+
}
16+
17+
describe(`${useEffectEvent.name}`, () => {
18+
it("Should maintain a stable reference across all renders", () => {
19+
const callback = jest.fn();
20+
const { result, rerender } = renderEffectEvent(callback);
21+
22+
const firstResult = result.current;
23+
for (let i = 0; i < 5; i++) {
24+
rerender({ callback });
25+
}
26+
27+
expect(result.current).toBe(firstResult);
28+
expect.hasAssertions();
29+
});
30+
31+
it("Should always call the most recent callback passed in", () => {
32+
let value: "A" | "B" | "C" = "A";
33+
const flipToB = () => {
34+
value = "B";
35+
};
36+
37+
const flipToC = () => {
38+
value = "C";
39+
};
40+
41+
const { result, rerender } = renderEffectEvent(flipToB);
42+
rerender({ callback: flipToC });
43+
44+
result.current();
45+
expect(value).toEqual("C");
46+
expect.hasAssertions();
47+
});
48+
});

site/src/hooks/hookPolyfills.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @file For defining DIY versions of official React hooks that have not been
3+
* released yet.
4+
*
5+
* These hooks should be deleted as soon as the official versions are available.
6+
* They do not have the same ESLinter exceptions baked in that the official
7+
* hooks do, especially for dependency arrays.
8+
*/
9+
import { useCallback, useEffect, useRef } from "react";
10+
11+
/**
12+
* A DIY version of useEffectEvent.
13+
*
14+
* Works like useCallback, except that it doesn't take a dependency array, and
15+
* always returns out a stable function on every single render. The returned-out
16+
* function is always able to "see" the most up-to-date version of the callback
17+
* passed in.
18+
*
19+
* Should only be used as a last resort when useCallback does not work, but you
20+
* still need to avoid dependency array violations. (e.g., You need an on-mount
21+
* effect, but an external library doesn't give their functions stable
22+
* references, so useEffect/useMemo/useCallback run too often).
23+
*
24+
* @see {@link https://react.dev/reference/react/experimental_useEffectEvent}
25+
*/
26+
export function useEffectEvent<TArgs extends unknown[], TReturn = unknown>(
27+
callback: (...args: TArgs) => TReturn,
28+
) {
29+
const callbackRef = useRef(callback);
30+
useEffect(() => {
31+
callbackRef.current = callback;
32+
}, [callback]);
33+
34+
return useCallback((...args: TArgs): TReturn => {
35+
return callbackRef.current(...args);
36+
}, []);
37+
}

site/src/pages/WorkspacesPage/WorkspacesPage.tsx

+65-8
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
useDashboard,
55
useIsWorkspaceActionsEnabled,
66
} from "components/Dashboard/DashboardProvider";
7-
import { FC, useEffect, useState } from "react";
7+
import { type FC, useEffect, useState, useSyncExternalStore } from "react";
88
import { Helmet } from "react-helmet-async";
99
import { pageTitle } from "utils/page";
1010
import { useWorkspacesData, useWorkspaceUpdate } from "./data";
@@ -21,15 +21,34 @@ import { MONOSPACE_FONT_FAMILY } from "theme/constants";
2121
import TextField from "@mui/material/TextField";
2222
import { displayError } from "components/GlobalSnackbar/utils";
2323
import { getErrorMessage } from "api/errors";
24+
import { useEffectEvent } from "hooks/hookPolyfills";
25+
26+
function useSafeSearchParams() {
27+
// Have to wrap setSearchParams because React Router doesn't make sure that
28+
// the function's memory reference stays stable on each render, even though
29+
// its logic never changes, and even though it has function update support
30+
const [searchParams, setSearchParams] = useSearchParams();
31+
const stableSetSearchParams = useEffectEvent(setSearchParams);
32+
33+
// Need this to be a tuple type, but can't use "as const", because that would
34+
// make the whole array readonly and cause type mismatches downstream
35+
return [searchParams, stableSetSearchParams] as ReturnType<
36+
typeof useSearchParams
37+
>;
38+
}
2439

2540
const WorkspacesPage: FC = () => {
2641
const [dormantWorkspaces, setDormantWorkspaces] = useState<Workspace[]>([]);
2742
// If we use a useSearchParams for each hook, the values will not be in sync.
2843
// So we have to use a single one, centralizing the values, and pass it to
2944
// each hook.
30-
const searchParamsResult = useSearchParams();
45+
const searchParamsResult = useSafeSearchParams();
3146
const pagination = usePagination({ searchParamsResult });
32-
const filterProps = useWorkspacesFilter({ searchParamsResult, pagination });
47+
const filterProps = useWorkspacesFilter({
48+
searchParamsResult,
49+
onFilterChange: () => pagination.goToPage(1),
50+
});
51+
3352
const { data, error, queryKey, refetch } = useWorkspacesData({
3453
...pagination,
3554
query: filterProps.filter.query,
@@ -121,20 +140,58 @@ const WorkspacesPage: FC = () => {
121140

122141
export default WorkspacesPage;
123142

143+
const workspaceFilterKey = "WorkspacesPage/filter";
144+
const defaultWorkspaceFilter = "owner:me";
145+
146+
// Function should stay outside components as much as possible; if declared
147+
// inside the component, React would add/remove event listeners every render
148+
function subscribeToFilterChanges(notifyReact: () => void) {
149+
const onStorageChange = (event: StorageEvent) => {
150+
const { key, storageArea, oldValue, newValue } = event;
151+
152+
const shouldNotify =
153+
key === workspaceFilterKey &&
154+
storageArea === window.localStorage &&
155+
newValue !== oldValue;
156+
157+
if (shouldNotify) {
158+
notifyReact();
159+
}
160+
};
161+
162+
window.addEventListener("storage", onStorageChange);
163+
return () => window.removeEventListener("storage", onStorageChange);
164+
}
165+
124166
type UseWorkspacesFilterOptions = {
125167
searchParamsResult: ReturnType<typeof useSearchParams>;
126-
pagination: ReturnType<typeof usePagination>;
168+
onFilterChange: () => void;
127169
};
128170

129171
const useWorkspacesFilter = ({
130172
searchParamsResult,
131-
pagination,
173+
onFilterChange,
132174
}: UseWorkspacesFilterOptions) => {
175+
// Using useSyncExternalStore store to safely access localStorage from the
176+
// first render; both snapshot callbacks return primitives, so no special
177+
// trickery needed to prevent hook from immediately blowing up in dev mode
178+
const localStorageFilter = useSyncExternalStore(
179+
subscribeToFilterChanges,
180+
() => {
181+
return (
182+
window.localStorage.getItem(workspaceFilterKey) ??
183+
defaultWorkspaceFilter
184+
);
185+
},
186+
() => defaultWorkspaceFilter,
187+
);
188+
133189
const filter = useFilter({
134-
initialValue: `owner:me`,
190+
fallbackFilter: localStorageFilter,
135191
searchParamsResult,
136-
onUpdate: () => {
137-
pagination.goToPage(1);
192+
onUpdate: (newValues) => {
193+
window.localStorage.setItem(workspaceFilterKey, newValues);
194+
onFilterChange();
138195
},
139196
});
140197

0 commit comments

Comments
 (0)