refactor: move all code to hypothetical useClassNames (just to see if it's possible - maybe this should all be deleted)

This commit is contained in:
Parkreiner 2024-02-25 19:08:49 +00:00
parent 154380b111
commit d650cfae22
17 changed files with 515 additions and 512 deletions

View File

@ -151,9 +151,7 @@ rules:
message: "Import from lodash/<name> instead."
no-unused-vars: "off"
"object-curly-spacing": "off"
react-hooks/exhaustive-deps:
- warn
- additionalHooks: "(useClassName)"
react-hooks/exhaustive-deps: warn
react-hooks/rules-of-hooks: error
react/display-name: "off"
react/jsx-no-script-url:

View File

@ -4,7 +4,7 @@ import { useTheme } from "@emotion/react";
import { type FC } from "react";
import type { WorkspaceBuild } from "api/typesGenerated";
import { getDisplayWorkspaceBuildStatus } from "utils/workspace";
import { useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
import { BuildIcon } from "components/BuildIcon/BuildIcon";
@ -13,13 +13,20 @@ export interface BuildAvatarProps {
size?: AvatarProps["size"];
}
type WorkspaceType = ReturnType<typeof getDisplayWorkspaceBuildStatus>["type"];
const useClassNames = makeClassNames<"badgeType", { type: WorkspaceType }>(
(css, theme) => ({
badgeType: ({ type }) => {
return css({ backgroundColor: theme.palette[type].light });
},
}),
);
export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
const theme = useTheme();
const { status, type } = getDisplayWorkspaceBuildStatus(theme, build);
const badgeType = useClassName(
(css, theme) => css({ backgroundColor: theme.palette[type].light }),
[type],
);
const { badgeType } = useClassNames({ type });
return (
<Badge

View File

@ -5,7 +5,7 @@ import Snackbar, {
import CloseIcon from "@mui/icons-material/Close";
import { type FC } from "react";
import { type Interpolation, type Theme } from "@emotion/react";
import { useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
type EnterpriseSnackbarVariant = "error" | "info" | "success";
@ -16,6 +16,21 @@ export interface EnterpriseSnackbarProps extends MuiSnackbarProps {
variant?: EnterpriseSnackbarVariant;
}
type HookInput = Readonly<{ variant: EnterpriseSnackbarVariant }>;
const useClassNames = makeClassNames<"content", HookInput>((css, theme) => ({
content: ({ variant }) => css`
border: 1px solid ${theme.palette.divider};
border-left: 4px solid ${variantColor(variant, theme)};
border-radius: 8px;
padding: 8px 24px 8px 16px;
box-shadow: ${theme.shadows[6]};
align-items: inherit;
background-color: ${theme.palette.background.paper};
color: ${theme.palette.text.secondary};
`,
}));
/**
* Wrapper around Material UI's Snackbar component, provides pre-configured
* themes and convenience props. Coder UI's Snackbars require a close handler,
@ -35,19 +50,7 @@ export const EnterpriseSnackbar: FC<EnterpriseSnackbarProps> = ({
action,
...snackbarProps
}) => {
const content = useClassName(
(css, theme) => css`
border: 1px solid ${theme.palette.divider};
border-left: 4px solid ${variantColor(variant, theme)};
border-radius: 8px;
padding: 8px 24px 8px 16px;
box-shadow: ${theme.shadows[6]};
align-items: inherit;
background-color: ${theme.palette.background.paper};
color: ${theme.palette.text.secondary};
`,
[variant],
);
const classNames = useClassNames({ variant });
return (
<Snackbar
@ -65,7 +68,7 @@ export const EnterpriseSnackbar: FC<EnterpriseSnackbarProps> = ({
}
ContentProps={{
...ContentProps,
className: content,
className: classNames.content,
}}
onClose={onClose}
{...snackbarProps}

View File

@ -1,8 +1,8 @@
import Badge from "@mui/material/Badge";
import Group from "@mui/icons-material/Group";
import { type FC } from "react";
import { type ClassName, useClassName } from "hooks/useClassName";
import { Avatar } from "components/Avatar/Avatar";
import { makeClassNames } from "hooks/useClassNames";
export interface GroupAvatarProps {
name: string;
@ -10,14 +10,14 @@ export interface GroupAvatarProps {
}
export const GroupAvatar: FC<GroupAvatarProps> = ({ name, avatarURL }) => {
const badge = useClassName((css, theme) => classNames.badge(css, theme), []);
const classNames = useClassNames(null);
return (
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={<Group />}
classes={{ badge }}
classes={{ badge: classNames.badge }}
>
<Avatar background src={avatarURL}>
{name}
@ -26,21 +26,20 @@ export const GroupAvatar: FC<GroupAvatarProps> = ({ name, avatarURL }) => {
);
};
const classNames = {
badge: (css, theme) =>
css({
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: "100%",
width: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
const useClassNames = makeClassNames((css, theme) => ({
badge: css({
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: "100%",
width: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
"& svg": {
width: 14,
height: 14,
},
}),
} satisfies Record<string, ClassName>;
"& svg": {
width: 14,
height: 14,
},
}),
}));

View File

@ -3,7 +3,7 @@ import { type CSSObject, type Interpolation, type Theme } from "@emotion/react";
import { type ElementType, type FC, type ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { Stack } from "components/Stack/Stack";
import { type ClassName, useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
interface SidebarProps {
children?: ReactNode;
@ -60,17 +60,15 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
href,
icon: Icon,
}) => {
const link = useClassName((css, theme) => classNames.link(css, theme), []);
const activeLink = useClassName(
(css, theme) => classNames.activeLink(css, theme),
[],
);
const classNames = useClassNames(null);
return (
<NavLink
end
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
className={({ isActive }) =>
cx([classNames.link, isActive && classNames.activeLink])
}
>
<Stack alignItems="center" spacing={1.5} direction="row">
<Icon css={{ width: 16, height: 16 }} />
@ -106,8 +104,8 @@ const styles = {
}),
} satisfies Record<string, Interpolation<Theme>>;
const classNames = {
link: (css, theme) => css`
const useClassNames = makeClassNames((css, theme) => ({
link: css`
color: inherit;
display: block;
font-size: 14px;
@ -123,7 +121,7 @@ const classNames = {
}
`,
activeLink: (css, theme) => css`
activeLink: css`
background-color: ${theme.palette.action.hover};
&:before {
@ -139,4 +137,4 @@ const classNames = {
border-bottom-left-radius: 8px;
}
`,
} satisfies Record<string, ClassName>;
}));

View File

@ -2,7 +2,7 @@ import { cx } from "@emotion/css";
import { type FC, type PropsWithChildren } from "react";
import { NavLink, NavLinkProps } from "react-router-dom";
import { Margins } from "components/Margins/Margins";
import { type ClassName, useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
export const Tabs: FC<PropsWithChildren> = ({ children }) => {
return (
@ -34,20 +34,16 @@ export const TabLink: FC<TabLinkProps> = ({
children,
...linkProps
}) => {
const tabLink = useClassName(
(css, theme) => classNames.tabLink(css, theme),
[],
);
const activeTabLink = useClassName(
(css, theme) => classNames.activeTabLink(css, theme),
[],
);
const classNames = useClassNames(null);
return (
<NavLink
className={({ isActive }) =>
cx([tabLink, isActive && activeTabLink, className])
cx([
classNames.tabLink,
isActive && classNames.activeTabLink,
className,
])
}
{...linkProps}
>
@ -56,8 +52,8 @@ export const TabLink: FC<TabLinkProps> = ({
);
};
const classNames = {
tabLink: (css, theme) => css`
const useClassNames = makeClassNames((css, theme) => ({
tabLink: css`
text-decoration: none;
color: ${theme.palette.text.secondary};
font-size: 14px;
@ -68,7 +64,7 @@ const classNames = {
color: ${theme.palette.text.primary};
}
`,
activeTabLink: (css, theme) => css`
activeTabLink: css`
color: ${theme.palette.text.primary};
position: relative;
@ -82,4 +78,4 @@ const classNames = {
position: absolute;
}
`,
} satisfies Record<string, ClassName>;
}));

View File

@ -1,216 +0,0 @@
import { type Theme, useTheme } from "@emotion/react";
import { css } from "@emotion/css";
import { useState } from "react";
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
type EmptyObject = Record<string, never>;
type CSSInput = Readonly<{
css: typeof css;
theme: Theme;
}>;
type ClassNameFunction<TInput extends NonNullable<unknown>> = (
args: CSSInput & TInput,
) => string;
type MakeClassNamesResult<
THookInput extends Record<string, Primitive>,
TConfig extends Record<string, ClassNameFunction<THookInput>>,
> = (hookInput: THookInput) => Readonly<Record<keyof TConfig, string>>;
/**
* Hook factory for giving you an escape hatch for making Emotion styles. This
* should be used as a last resort; use the React CSS prop whenever possible.
*
* ---
*
* Sometimes you need to combine/collate styles using the className prop in a
* component, but Emotion does not give you an easy way to define a className
* and use it from within the same component.
*
* Other times, you need to use inputs that will change on each render to make
* your styles, but you only want the styles relying on those inputs to be
* re-computed when the input actually changes. Otherwise, CSS will keep
* thrashing the <style> tag with diffs each and every render.
*
* Also, sometimes just you don't want to think about dependency arrays and
* stale closure issues.
*
* This function solves all three problems. The custom hook it returns will give
* you a type-safe collection of className values, and auto-memoize all inputs,
* only re-computing new CSS styles when one of the inputs changes by reference.
*
* Making that memoization possible comes with two caveats:
* 1. All inputs fed into the hook must be primitives. No nested objects or
* functions or arrays.
* 2. All styles defined via the hook are tied to the same memoization cache. If
* one of the inputs changes, all classnames for the hook will be
* re-computed, even if none of the classnames actually use the input that
* changed.
*
* If (2) is a performance problem, you can define separate hooks by calling
* makeClassNames multiple times for each hook you need.
*/
export function makeClassNames<
THookInput extends Record<string, Primitive> = EmptyObject,
TConfig extends Record<string, ClassNameFunction<THookInput>> = Record<
string,
ClassNameFunction<THookInput>
>,
>(styleConfig: TConfig): MakeClassNamesResult<THookInput, TConfig> {
type StyleRecord = Record<keyof TConfig, string>;
const computeNewStyles = (theme: Theme, hookInput: THookInput) => {
const result: Partial<StyleRecord> = {};
for (const key in styleConfig) {
const configFunc = styleConfig[key];
result[key] = configFunc({ css, theme, ...hookInput });
}
return result as Readonly<StyleRecord>;
};
const didInputsChangeByValue = (
inputs1: THookInput,
inputs2: THookInput,
): boolean => {
for (const key in inputs1) {
const value1 = inputs1[key];
const value2 = inputs2[key];
if (Number.isNaN(value1) && Number.isNaN(value2)) {
continue;
}
if (value1 !== value2) {
return true;
}
}
return false;
};
return function useClassNames(hookInputs) {
const activeTheme = useTheme();
const computeNewCacheValue = () => ({
theme: activeTheme,
inputs: hookInputs,
styles: computeNewStyles(activeTheme, hookInputs),
});
const [cache, setCache] = useState(computeNewCacheValue);
const needNewStyles =
cache.theme !== activeTheme ||
didInputsChangeByValue(cache.inputs, hookInputs);
if (needNewStyles) {
setCache(computeNewCacheValue());
}
return cache.styles;
};
}
/**
* Issues left to figure out:
* 1. Bare minimum, you'll almost always want to pass in one type parameter for
* the additional inputs that you're accessing. Having one explicit type
* parameter should not break type inference for the other type parameter,
* and destroy auto-complete for classNames's properties
* 2. If the hook is being called with no additional inputs, it'd be nice if you
* could just call the hook with no arguments whatsoever
*/
type HookInput = Readonly<{
paddingTop: number;
variant: "contained" | "stroked";
}>;
const useClassNames = makeClassNames<HookInput>({
class1: ({ css, theme, paddingTop }) => css`
background-color: red;
padding: ${theme.spacing(2)};
padding-top: ${paddingTop}px;
`,
class2: ({ css, variant }) => css`
color: ${variant === "contained" ? "red" : "blue"};
`,
});
/**
* Idea I have - there are two main benefits to having the main function
* argument be defined like this:
* 1. Gives you another function boundary, so TConfig should hopefully be
* bindable to it. That lets you split up the type parameters and gives you
* more options for restoring type inference/auto-complete
* 2. The logic centralizes the css and theme arguments, which reduces keyboard
* typing
*/
// const useRevampedClassNames = makeClassNames<HookInput>((css, theme) => ({
// class1: ({ paddingTop }) => css`
// background-color: red;
// padding: ${theme.spacing(2)};
// padding-top: ${paddingTop}px;
// `,
// class2: ({ variant }) => css`
// color: ${variant === "contained" ? "red" : "blue"};
// `,
// }));
// function makeClassNames2(styleFunction) {
// const computeNewStyles = (theme, hookInput) => {
// const stylesObject = styleFunction(css, theme);
// const result = {};
// for (const key in stylesObject) {
// const configFunc = stylesObject[key];
// result[key] = configFunc(hookInput);
// }
// return result;
// };
// const didInputsChangeByValue = (inputs1, inputs2) => {
// for (const key in inputs1) {
// const value1 = inputs1[key];
// const value2 = inputs2[key];
// if (Number.isNaN(value1) && Number.isNaN(value2)) {
// continue;
// }
// if (value1 !== value2) {
// return true;
// }
// }
// return false;
// };
// return function useClassNames(hookInput) {
// const activeTheme = useTheme();
// const computeNewCacheValue = () => ({
// prevTheme: activeTheme,
// prevInput: hookInput,
// styles: computeNewStyles(activeTheme, hookInput),
// });
// const [cache, setCache] = useState(computeNewCacheValue);
// const needNewStyles =
// cache.prevTheme !== activeTheme ||
// didInputsChangeByValue(cache.prevInput, hookInput);
// if (needNewStyles) {
// setCache(computeNewCacheValue());
// }
// return cache.styles;
// };
// }
export function useTempBlah() {
const classNames = useClassNames({ variant: "contained", paddingTop: 12 });
return classNames;
}

View File

@ -1,99 +0,0 @@
import { type DependencyList } from "react";
import { type ClassName, useClassName } from "./useClassName";
import { type RenderHookOptions, renderHook } from "@testing-library/react";
import { ThemeProvider } from "@emotion/react";
import { ThemeOverride } from "contexts/ThemeProvider";
import themes from "theme";
/**
* Treating the string that Emotion generates for the hook as an opaque value.
* Trying to make assertions on the format could lead to flakier tests.
*/
describe(useClassName.name, () => {
type Props = Readonly<{ styles: ClassName; deps: DependencyList }>;
function renderUseClassNames(
styles: ClassName,
deps: DependencyList,
options: Omit<RenderHookOptions<Props>, "initialProps"> = {},
) {
return renderHook<string, Props>(
/* eslint-disable-next-line react-hooks/exhaustive-deps --
Disabling to make test setup easier; disabling this rule should still
be treated as a bad idea in general */
({ styles, deps }) => useClassName(styles, deps),
{ initialProps: { styles, deps }, ...options },
);
}
test("Two separate hook calls with the same styles should produce the same string (regardless of function references)", () => {
const func1: ClassName = (css) => css`
background-color: chartreuse;
`;
const func2: ClassName = (css) => css`
background-color: chartreuse;
`;
const { result: result1 } = renderUseClassNames(func1, []);
const { result: result2 } = renderUseClassNames(func2, []);
expect(result1.current).toBe(result2.current);
});
it("Only re-evaluates the styles callback if the dependencies change during re-renders", () => {
let color: "red" | "blue" = "red";
const className: ClassName = (css) => css`
color: ${color};
`;
const mockCallback = jest.fn(className);
const { result, rerender } = renderUseClassNames(mockCallback, [color]);
expect(mockCallback).toBeCalledTimes(1);
const initialResult = result.current;
rerender({ styles: mockCallback, deps: [color] });
expect(result.current).toEqual(initialResult);
expect(mockCallback).toBeCalledTimes(1);
color = "blue";
rerender({ styles: mockCallback, deps: [color] });
expect(result.current).not.toEqual(initialResult);
expect(mockCallback).toBeCalledTimes(2);
});
it("Should use the closest theme provider available in the React tree", () => {
const className: ClassName = (css, theme) => css`
background-color: ${theme.roles.active.background};
`;
const { result: result1 } = renderUseClassNames(className, [], {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
),
});
const { result: result2 } = renderUseClassNames(className, [], {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>
<ThemeOverride theme={themes.light}>{children}</ThemeOverride>
</ThemeProvider>
),
});
const { result: result3 } = renderUseClassNames(className, [], {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>
<ThemeOverride theme={themes.light}>
<ThemeOverride theme={themes.darkBlue}>
<ThemeOverride theme={themes.light}>
<ThemeOverride theme={themes.dark}>{children}</ThemeOverride>
</ThemeOverride>
</ThemeOverride>
</ThemeOverride>
</ThemeProvider>
),
});
expect(result1.current).not.toEqual(result2.current);
expect(result1.current).toEqual(result3.current);
});
});

View File

@ -1,28 +0,0 @@
/**
* @file This hook has had the ESLint exhaustive-deps rule added to it.
*/
import { type DependencyList, useMemo } from "react";
import { useEffectEvent } from "./hookPolyfills";
import { type Theme, useTheme } from "@emotion/react";
import { css } from "@emotion/css";
export type ClassName = (cssFn: typeof css, theme: Theme) => string;
/**
* An escape hatch for when you really need to manually pass around a
* `className`. Prefer using the `css` prop whenever possible. If you
* can't use that, then this might be helpful for you.
*/
export function useClassName(styles: ClassName, deps: DependencyList): string {
const theme = useTheme();
const stableStylesCallback = useEffectEvent(styles);
return useMemo(
() => stableStylesCallback(css, theme),
/* eslint-disable-next-line react-hooks/exhaustive-deps --
Hook needs to be able to handle variadic number of dependencies at the
API level. There should be a custom ESLint rule set to ensure that the
number of dependencies doesn't change across renders. */
[stableStylesCallback, theme, ...deps],
);
}

View File

@ -0,0 +1,181 @@
import { type StylesFunction, makeClassNames } from "./useClassNames";
import { RenderHookOptions, renderHook } from "@testing-library/react";
import { css as emotionCss } from "@emotion/css";
import { type Theme, ThemeProvider } from "@emotion/react";
import { ThemeOverride } from "contexts/ThemeProvider";
import themes from "theme";
type HookProps = Readonly<{
color: "red" | "blue";
}>;
function setupUseClassNames<TKey extends string = string>(
implementation: StylesFunction<TKey, HookProps>,
options?: Omit<RenderHookOptions<HookProps>, "initialProps">,
) {
const mockCallback = jest.fn(implementation);
const useClassNames = makeClassNames(mockCallback);
const { wrapper, ...delegatedOptions } = options ?? {};
type Result = ReturnType<typeof useClassNames>;
const { result, rerender, unmount } = renderHook<Result, HookProps>(
({ color }) => useClassNames({ color }),
{
...delegatedOptions,
initialProps: { color: "red" },
wrapper:
wrapper ??
(({ children }) => (
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
)),
},
);
return { result, rerender, unmount, mockCallback };
}
/**
* Treating the string that Emotion generates for the hook as an opaque value.
* Trying to make assertions on the format could lead to flakier tests.
*/
describe(makeClassNames.name, () => {
test("Hook output should be fully deterministic based on theme and inputs", () => {
const { result } = setupUseClassNames((css, theme) => ({
class1: css`
background-color: chartreuse;
`,
class2: css`
background-color: chartreuse;
`,
class3: css`
background-color: ${theme.roles.active.background};
`,
class4: css`
background-color: ${theme.roles.active.background};
`,
}));
const { current } = result;
expect(current.class1).toEqual(current.class2);
expect(current.class3).toEqual(current.class4);
});
it("Only re-evaluates the styles callback if the dependencies change during re-renders", () => {
const { result, rerender, unmount, mockCallback } = setupUseClassNames(
(css) => ({
testClass: ({ color }) => css`
color: ${color};
`,
}),
);
const initialResult = result.current.testClass;
expect(mockCallback).toBeCalledTimes(1);
rerender({ color: "red" });
expect(mockCallback).toBeCalledTimes(1);
expect(result.current.testClass).toEqual(initialResult);
rerender({ color: "blue" });
expect(mockCallback).toBeCalledTimes(2);
expect(result.current).not.toEqual(initialResult);
unmount();
});
it("Should use the closest theme provider available in the React tree", () => {
const useClassNames = makeClassNames((css, theme) => ({
testClass: css`
background-color: ${theme.roles.active.background};
`,
}));
const { result: result1 } = renderHook(useClassNames, {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>{children}</ThemeProvider>
),
});
const { result: result2 } = renderHook(useClassNames, {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>
<ThemeOverride theme={themes.light}>{children}</ThemeOverride>
</ThemeProvider>
),
});
const { result: result3 } = renderHook(useClassNames, {
wrapper: ({ children }) => (
<ThemeProvider theme={themes.dark}>
<ThemeOverride theme={themes.light}>
<ThemeOverride theme={themes.darkBlue}>
<ThemeOverride theme={themes.light}>
<ThemeOverride theme={themes.dark}>{children}</ThemeOverride>
</ThemeOverride>
</ThemeOverride>
</ThemeOverride>
</ThemeProvider>
),
});
expect(result1.current.testClass).not.toEqual(result2.current.testClass);
expect(result1.current.testClass).toEqual(result3.current.testClass);
});
it("Should recalculate all defined styles when any of the dependencies change (even if a style doesn't use them)", () => {
const noInputsCallback = jest.fn(
(css: typeof emotionCss) => css`
color: yellow;
`,
);
const { rerender } = setupUseClassNames((css) => ({
withInputs: ({ color }) => css`
background-color: ${color};
`,
noInputs: () => noInputsCallback(css),
}));
expect(noInputsCallback).toBeCalledTimes(1);
rerender({ color: "blue" });
expect(noInputsCallback).toBeCalledTimes(2);
});
it("Should calculate new styles immediately when dependencies change (no stale closure issues or delays via async tasks)", () => {
let activeTheme: Theme = themes.dark;
const { result, rerender, mockCallback } = setupUseClassNames(
(css, theme) => ({
testClass: ({ color }) => css`
color: ${color};
background-color: ${theme.roles.active.background};
`,
}),
{
wrapper: ({ children }) => (
<ThemeProvider theme={activeTheme}>{children}</ThemeProvider>
),
},
);
const initialResult = result.current.testClass;
expect(mockCallback).toBeCalledTimes(1);
activeTheme = themes.light;
rerender({ color: "red" });
expect(mockCallback).toBeCalledTimes(2);
const themeChangeResult = result.current.testClass;
expect(themeChangeResult).not.toEqual(initialResult);
rerender({ color: "blue" });
expect(mockCallback).toBeCalledTimes(3);
const colorChangeResult = result.current.testClass;
expect(colorChangeResult).not.toEqual(initialResult);
expect(colorChangeResult).not.toEqual(themeChangeResult);
});
});

View File

@ -0,0 +1,171 @@
import { useReducer } from "react";
import { type Theme, useTheme } from "@emotion/react";
import { css as emotionCss } from "@emotion/css";
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
type PrimitiveRecord = Record<string, Primitive>;
type EmptyRecord = Record<string, never>;
type MapRawInputToHookInput<TRawInput extends PrimitiveRecord> = [
TRawInput,
] extends [never]
? EmptyRecord | null
: TRawInput;
type ClassNameFunction<T extends PrimitiveRecord> = (
input: MapRawInputToHookInput<T>,
) => string;
export type StylesFunction<
TKey extends string,
TRawHookInput extends PrimitiveRecord,
> = (
css: typeof emotionCss,
theme: Theme,
) => Record<TKey, string | ClassNameFunction<TRawHookInput>>;
type UseClassNamesCustomHookResult<TKey extends string> = Record<TKey, string>;
type UseClassNamesCustomHook<
TKey extends string,
TRawHookInput extends PrimitiveRecord,
> = (
input: MapRawInputToHookInput<TRawHookInput>,
) => Readonly<UseClassNamesCustomHookResult<TKey>>;
/**
* Hook factory for giving you an escape hatch for making Emotion styles. This
* should be used as a last resort; use the React CSS prop whenever possible.
*
* ---
*
* Sometimes you need to combine/collate styles using the `className` prop in a
* component, but Emotion does not give you an easy way to define a className
* and use it from within the same component.
*
* Other times, you need to use inputs that will change on each render to make
* your styles, but you only want the styles relying on those inputs to be
* re-computed when the input actually changes. Otherwise, Emotion's CSS
* function might keep thrashing the <style> tag with diffs each and every
* render.
*
* And sometimes just you don't want to think about dependency arrays and
* stale closure issues.
*
* This function solves all three problems. The custom hook it returns will give
* you a type-safe collection of className values, and auto-memoize all inputs,
* only re-computing new CSS styles when one of the inputs changes by reference.
*
* Making that memoization possible comes with two caveats:
* 1. All inputs fed into the hook must be primitives. No nested objects or
* functions or arrays.
* 2. All styles defined via the hook are tied to the same memoization cache. If
* one of the inputs changes, all classnames for the hook will be
* re-computed, even if none of the classnames actually use the input that
* changed.
*
* If (2) is a performance problem, you can define separate hooks by calling
* makeClassNames multiple times for each hook you need.
*/
export function makeClassNames<
/*
Making the user specify the keys first is a heavy-handed way to circumvent
TypeScript language limitations and make sure type safety doesn't degrade.
TS type inference is all or nothing; if you specify one type parameter,
type inference stops and default values kick in for all other parameters
If the input were specified first, a dev could accidentally specify only
the input. That would cause the key to get inferred as type string, and
they would lose auto-complete and protection against accidental typos.
Ergonomics are slightly worse this way, but that should be fine for what'
a last-resort escape hatch anyway
Could maybe get cleaned up if private type parameters ever become a thing
*/
TKey extends string = string,
TRawHookInput extends PrimitiveRecord = never,
>(
styleFunction: StylesFunction<TKey, TRawHookInput>,
): UseClassNamesCustomHook<TKey, TRawHookInput> {
type Result = UseClassNamesCustomHookResult<TKey>;
type HookInput = MapRawInputToHookInput<TRawHookInput>;
const computeNewStyles = (
theme: Theme,
hookInput: HookInput,
): Readonly<Result> => {
const stylesObject = styleFunction(emotionCss, theme);
const result: Partial<Result> = {};
for (const key in stylesObject) {
const styleValue = stylesObject[key];
const resultValue =
typeof styleValue === "string" ? styleValue : styleValue(hookInput);
result[key] = resultValue;
}
return result as Result;
};
// This function could technically be defined outside, since it doesn't rely
// on closure, but feeding in the right type info got too awkward
const didInputsChangeByValue = (
hookInput1: HookInput,
hookInput2: HookInput,
): boolean => {
// Slightly clunky syntax, but have to do it this way to make the compiler
// happy and not make it worry about null values in the loop
if (hookInput1 === null) {
if (hookInput2 === null) {
return false;
}
return true;
} else if (hookInput2 === null) {
return true;
}
for (const key in hookInput1) {
const value1 = hookInput1[key];
const value2 = hookInput2[key];
if (Number.isNaN(value1) && Number.isNaN(value2)) {
continue;
}
if (value1 !== value2) {
return true;
}
}
return false;
};
return function useClassNames(hookInput) {
const activeTheme = useTheme();
const getNewCacheValue = () => ({
prevTheme: activeTheme,
prevInput: hookInput,
styles: computeNewStyles(activeTheme, hookInput),
});
const [cache, updateCacheAndRedoRender] = useReducer(
getNewCacheValue,
null,
getNewCacheValue,
);
const needNewStyles =
hookInput !== null &&
(cache.prevTheme !== activeTheme ||
didInputsChangeByValue(cache.prevInput, hookInput));
if (needNewStyles) {
updateCacheAndRedoRender();
}
return cache.styles;
};
}

View File

@ -40,7 +40,7 @@ import colors from "theme/tailwindColors";
import { getDisplayWorkspaceStatus } from "utils/workspace";
import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip";
import { Stack } from "components/Stack/Stack";
import { type ClassName, useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
export const bannerHeight = 36;
@ -50,16 +50,27 @@ export interface DeploymentBannerViewProps {
fetchStats?: () => void;
}
const useDeploymentBannerClassNames = makeClassNames((css, theme) => ({
summaryTooltip: css`
${theme.typography.body2 as CSSObject}
margin: 0 0 4px 12px;
width: 400px;
padding: 16px;
color: ${theme.palette.text.primary};
background-color: ${theme.palette.background.paper};
border: 1px solid ${theme.palette.divider};
pointer-events: none;
`,
}));
export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
health,
stats,
fetchStats,
}) => {
const theme = useTheme();
const summaryTooltip = useClassName(
(css, theme) => classNames.summaryTooltip(css, theme),
[],
);
const classNames = useDeploymentBannerClassNames(null);
const aggregatedMinutes = useMemo(() => {
if (!stats) {
@ -133,7 +144,7 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
}}
>
<Tooltip
classes={{ tooltip: summaryTooltip }}
classes={{ tooltip: classNames.summaryTooltip }}
title={
healthErrors.length > 0 ? (
<>
@ -415,20 +426,6 @@ const getHealthErrors = (health: HealthcheckReport) => {
return warnings;
};
const classNames = {
summaryTooltip: (css, theme) => css`
${theme.typography.body2 as CSSObject}
margin: 0 0 4px 12px;
width: 400px;
padding: 16px;
color: ${theme.palette.text.primary};
background-color: ${theme.palette.background.paper};
border: 1px solid ${theme.palette.divider};
pointer-events: none;
`,
} satisfies Record<string, ClassName>;
const styles = {
statusBadge: (theme) => css`
display: flex;

View File

@ -13,7 +13,6 @@ import type {
WorkspaceAgentListeningPortsResponse,
} from "api/typesGenerated";
import { portForwardURL } from "utils/portForward";
import { type ClassName, useClassName } from "hooks/useClassName";
import {
HelpTooltipLink,
HelpTooltipLinksGroup,
@ -26,6 +25,7 @@ import {
PopoverTrigger,
} from "components/Popover/Popover";
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
import { makeClassNames } from "hooks/useClassNames";
export interface PortForwardButtonProps {
host: string;
@ -41,10 +41,18 @@ export interface PortForwardButtonProps {
};
}
const useClassNames = makeClassNames((css, theme) => ({
paper: css`
padding: 0;
width: 304px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
}));
export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
const { agent, storybook } = props;
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
const classNames = useClassNames(null);
const portsQuery = useQuery({
queryKey: ["portForward", agent.id],
@ -77,7 +85,7 @@ export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
Open ports
</Button>
</PopoverTrigger>
<PopoverContent horizontal="right" classes={{ paper }}>
<PopoverContent horizontal="right" classes={{ paper: classNames.paper }}>
<PortForwardPopoverView {...props} ports={data?.ports} />
</PopoverContent>
</Popover>
@ -203,15 +211,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
);
};
const classNames = {
paper: (css, theme) => css`
padding: 0;
width: 304px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
} satisfies Record<string, ClassName>;
const styles = {
portCount: (theme) => ({
fontSize: 12,

View File

@ -6,7 +6,6 @@ import {
HelpTooltipText,
} from "components/HelpTooltip/HelpTooltip";
import { docs } from "utils/docs";
import { type ClassName, useClassName } from "hooks/useClassName";
import { CodeExample } from "components/CodeExample/CodeExample";
import {
Popover,
@ -16,6 +15,7 @@ import {
import { Stack } from "components/Stack/Stack";
import Button from "@mui/material/Button";
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
import { makeClassNames } from "hooks/useClassNames";
export interface SSHButtonProps {
workspaceName: string;
@ -24,13 +24,22 @@ export interface SSHButtonProps {
sshPrefix?: string;
}
const useClassNames = makeClassNames((css, theme) => ({
paper: css`
padding: 16px 24px 24px;
width: 304px;
color: ${theme.palette.text.secondary};
margin-top: 2px;
`,
}));
export const SSHButton: FC<SSHButtonProps> = ({
workspaceName,
agentName,
isDefaultOpen = false,
sshPrefix,
}) => {
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
const classNames = useClassNames(null);
return (
<Popover isDefaultOpen={isDefaultOpen}>
@ -45,7 +54,7 @@ export const SSHButton: FC<SSHButtonProps> = ({
</Button>
</PopoverTrigger>
<PopoverContent horizontal="right" classes={{ paper }}>
<PopoverContent horizontal="right" classes={{ paper: classNames.paper }}>
<HelpTooltipText>
Run the following commands to connect with SSH:
</HelpTooltipText>
@ -92,15 +101,6 @@ export const SSHButton: FC<SSHButtonProps> = ({
);
};
const classNames = {
paper: (css, theme) => css`
padding: 16px 24px 24px;
width: 304px;
color: ${theme.palette.text.secondary};
margin-top: 2px;
`,
} satisfies Record<string, ClassName>;
const styles = {
codeExamples: {
marginTop: 12,

View File

@ -11,8 +11,8 @@ import { Pill } from "components/Pill/Pill";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { DormantDeletionText } from "./DormantDeletionText";
import { getDisplayWorkspaceStatus } from "utils/workspace";
import { useClassName } from "hooks/useClassName";
import { formatDistanceToNow } from "date-fns";
import { makeClassNames } from "hooks/useClassNames";
export type WorkspaceStatusBadgeProps = {
workspace: Workspace;
@ -172,21 +172,22 @@ export const WorkspaceStatusText: FC<WorkspaceStatusBadgeProps> = ({
);
};
const useClassNames = makeClassNames((css, theme) => ({
popper: css`
& .${tooltipClasses.tooltip} {
background-color: ${theme.palette.background.paper};
border: 1px solid ${theme.palette.divider};
font-size: 12px;
padding: 8px 10px;
}
`,
}));
const FailureTooltip: FC<TooltipProps> = ({ children, ...tooltipProps }) => {
const popper = useClassName(
(css, theme) => css`
& .${tooltipClasses.tooltip} {
background-color: ${theme.palette.background.paper};
border: 1px solid ${theme.palette.divider};
font-size: 12px;
padding: 8px 10px;
}
`,
[],
);
const classNames = useClassNames(null);
return (
<Tooltip {...tooltipProps} classes={{ popper }}>
<Tooltip {...tooltipProps} classes={{ popper: classNames.popper }}>
{children}
</Tooltip>
);

View File

@ -12,16 +12,17 @@ import { NavLink, Outlet } from "react-router-dom";
import kebabCase from "lodash/fp/kebabCase";
import { health, refreshHealth } from "api/queries/debug";
import type { HealthSeverity } from "api/typesGenerated";
import { type ClassName, useClassName } from "hooks/useClassName";
import { pageTitle } from "utils/page";
import { createDayString } from "utils/createDayString";
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
import { Loader } from "components/Loader/Loader";
import { HealthIcon } from "./Content";
import { makeClassNames } from "hooks/useClassNames";
export const HealthLayout: FC = () => {
const theme = useTheme();
const queryClient = useQueryClient();
const classNames = useClassNames(null);
const { data: healthStatus } = useQuery({
...health(),
refetchInterval: 30_000,
@ -39,12 +40,6 @@ export const HealthLayout: FC = () => {
} as const;
const visibleSections = filterVisibleSections(sections);
const link = useClassName((css, theme) => classNames.link(css, theme), []);
const activeLink = useClassName(
(css, theme) => classNames.activeLink(css, theme),
[],
);
return (
<>
<Helmet>
@ -169,7 +164,10 @@ export const HealthLayout: FC = () => {
key={key}
to={`/health/${kebabCase(key)}`}
className={({ isActive }) =>
cx([link, isActive && activeLink])
cx([
classNames.link,
isActive && classNames.activeLink,
])
}
>
<HealthIcon
@ -224,34 +222,32 @@ const filterVisibleSections = <T extends object>(sections: T) => {
);
};
const classNames = {
link: (css, theme) =>
css({
background: "none",
pointerEvents: "auto",
color: theme.palette.text.secondary,
border: "none",
fontSize: 14,
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
height: 36,
padding: "0 24px",
cursor: "pointer",
textDecoration: "none",
const useClassNames = makeClassNames((css, theme) => ({
link: css({
background: "none",
pointerEvents: "auto",
color: theme.palette.text.secondary,
border: "none",
fontSize: 14,
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
height: 36,
padding: "0 24px",
cursor: "pointer",
textDecoration: "none",
"&:hover": {
background: theme.palette.action.hover,
color: theme.palette.text.primary,
},
}),
activeLink: (css, theme) =>
css({
"&:hover": {
background: theme.palette.action.hover,
pointerEvents: "none",
color: theme.palette.text.primary,
}),
} satisfies Record<string, ClassName>;
},
}),
activeLink: css({
background: theme.palette.action.hover,
pointerEvents: "none",
color: theme.palette.text.primary,
}),
}));

View File

@ -18,7 +18,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { type ClassName, useClassName } from "hooks/useClassName";
import { makeClassNames } from "hooks/useClassNames";
const roleDescriptions: Record<string, string> = {
owner:
@ -86,7 +86,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
userLoginType,
oidcRoleSync,
}) => {
const paper = useClassName((css, theme) => classNames.paper(css, theme), []);
const classNames = useClassNames(null);
const handleChange = (roleName: string) => {
if (selectedRoleNames.has(roleName)) {
@ -127,7 +127,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
</IconButton>
</PopoverTrigger>
<PopoverContent classes={{ paper }}>
<PopoverContent classes={{ paper: classNames.paper }}>
<fieldset
css={styles.fieldset}
disabled={isLoading}
@ -162,13 +162,13 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
);
};
const classNames = {
paper: (css, theme) => css`
const useClassNames = makeClassNames((css, theme) => ({
paper: css`
width: 360px;
margin-top: 8px;
background: ${theme.palette.background.paper};
`,
} satisfies Record<string, ClassName>;
}));
const styles = {
editButton: (theme) => ({