Skip to content

fix(site): tighten interface design for various frontend utility functions #18894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

Parkreiner
Copy link
Member

@Parkreiner Parkreiner commented Jul 16, 2025

Precursor to #18895
Splitting this off so that the changes are easier to review.

Changes made

  • Improve type-safety for the withQuery Storybook decorator
  • Centralized almost all queries that deal with template versions to use a shared, exported query key prefix
  • Updated useFilter and usePagination to have much more strict return types, and decoupled them from React Router at the interface level
  • Also added some extra input validation and performance optimizations to useFilter and usePagination
  • Removed a stale data when working with checked workspaces for the /workspaces page
  • Removed hacky useEffect call for syncing URL state to checked workspaces, in favor of explicit state syncs
  • Did some extra cleanup and removed unused values

Notes

  • Many of the changes here were in service of the main PR. I'll try to highlight notable areas, but if there's anything that's not clear, feel free to post a comment in this PR. Ideally you shouldn't really have to look at the other PR to understand this one, so if something's confusing, that's a sign that something's wrong

@Parkreiner Parkreiner self-assigned this Jul 16, 2025
@Parkreiner Parkreiner requested a review from aslilac as a code owner July 16, 2025 01:40
@Parkreiner Parkreiner changed the title fix(site): tighten up interface design for various frontend utility functions fix(site): tighten interface design for various frontend utility functions Jul 16, 2025
Comment on lines -51 to -64
const getTemplatesByOrganizationQueryKey = (
organization: string,
options?: GetTemplatesOptions,
) => [organization, "templates", options?.deprecated];

const templatesByOrganization = (
organization: string,
options: GetTemplatesOptions = {},
) => {
return {
queryKey: getTemplatesByOrganizationQueryKey(organization, options),
queryFn: () => API.getTemplatesByOrganization(organization, options),
};
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weren't used at all

@@ -121,9 +106,11 @@ export const templateExamples = () => {
};
};

export const templateVersionRoot: string = "templateVersion";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ends up being more important for the upcoming PR, but I really, really think that we need to find the time to formalize our entire data layer, and be way more deliberate with our React Query caching

I fully expect our caching to create a super, super hard-to-recreate bug sometime in the future, because it's a bunch of semi-structured global mutable state right now

Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also added an explicit string type annotation so that TypeScript doesn't over-infer the value and treat it as a literal. We shouldn't know what exact combination of characters it is – just what purpose the string serves in the greater codebase as an opaque value

Comment on lines +33 to +34
searchParams: URLSearchParams;
onSearchParamsChange: (newParams: URLSearchParams) => void;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decoupled type signature from React Router. Splitting this up gives us a lot more freedom, which comes up later in this PR

Comment on lines +38 to +45
export type UseFilterResult = Readonly<{
query: string;
values: FilterValues;
used: boolean;
update: (newValues: string | FilterValues) => void;
debounceUpdate: (newValues: string | FilterValues) => void;
cancelDebounce: () => void;
}>;
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the previous type was super, super risky, because it was derived from a function that didn't have much type-safety

With this, the contract is way more explicit and simple, and there's basically no chance of the TypeScript LSP getting confused and dropping properties for tooltips

Comment on lines +10 to +15
type UsePaginationResult = Readonly<{
page: number;
limit: number;
offset: number;
goToPage: (page: number) => void;
}>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were missing a return type altogether before

Comment on lines +30 to +34
const abortNavigation =
page === newPage || !Number.isFinite(page) || !Number.isInteger(page);
if (abortNavigation) {
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another render optimization

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this render optimization? Not re-rendering the page if it's the same page?

Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there's two parts:

  • The basic input validation from the Number static methods
  • The equality check

I could maybe add a comment, but React only has Object.is for change detection, so in general, a lot of render optimizations boil down to "only dispatch a new value (especially a non-primitive value) when you know the contents have changed". Most of the time, you don't have to worry about over-dispatching primitive values (as long as they're done outside a render, React will detect that nothing changed and do a no-op for the state update). But because JS is wacky, there are edge cases with Object.is and numbers, which is why there's the extra === check

const [suggestedName, setSuggestedName] = useState(() =>
generateWorkspaceName(),
);
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName);
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra wrapper function was redundant

(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
{},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed type assertion to remove risk of accidentally silencing the compiler

Comment on lines +70 to +72
const [checkedWorkspaceIds, setCheckedWorkspaceIds] = useState(
new Set<string>(),
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part of a fix for a super nasty bug that I ran into when updating the batch-update UI

Because we were storing an entire copy of the original workspaces each time we checked off something, that also meant that as the query cache updated its data and triggered re-renders, anything that used the checked workspaces would receive the stale versions of the workspaces

"delete" | "update" | null
>(null);
const [urlSearchParams] = searchParamsResult;
const [activeBatchAction, setActiveBatchAction] = useState<BatchAction>();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly re-jiggered confirmingBatchAction

@@ -142,7 +164,18 @@ const WorkspacesPage: FC = () => {
canCreateTemplate={permissions.createTemplates}
canChangeVersions={permissions.updateTemplates}
checkedWorkspaces={checkedWorkspaces}
onCheckChange={setCheckedWorkspaces}
onCheckChange={(newWorkspaces) => {
setCheckedWorkspaceIds((current) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another render optimization

onBatchDeleteTransition={() => setActiveBatchAction("delete")}
onBatchStartTransition={() => batchActions.start(checkedWorkspaces)}
onBatchStopTransition={() => batchActions.stop(checkedWorkspaces)}
onBatchUpdateTransition={() => {
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change becomes a little more relevant for the other PR, but even if this were the only PR, this would still help the update UI avoid stale state

Comment on lines +15 to +25
type UseBatchActionsResult = Readonly<{
isProcessing: boolean;
start: (workspaces: readonly Workspace[]) => Promise<WorkspaceBuild[]>;
stop: (workspaces: readonly Workspace[]) => Promise<WorkspaceBuild[]>;
delete: (workspaces: readonly Workspace[]) => Promise<WorkspaceBuild[]>;
updateTemplateVersions: (
payload: UpdateAllPayload,
) => Promise<WorkspaceBuild[]>;
favorite: (payload: readonly Workspace[]) => Promise<void>;
unfavorite: (payload: readonly Workspace[]) => Promise<void>;
}>;
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explicit return type, which was super important here, because the function's return type was getting inferred as something super complicated and nasty from React Query

@Parkreiner Parkreiner requested a review from brettkolodny July 16, 2025 02:09
Comment on lines +80 to +88
type WorkspaceFilterProps = Readonly<{
filter: UseFilterResult;
error: unknown;
templateMenu: TemplateFilterMenu;
statusMenu: StatusFilterMenu;

userMenu?: UserFilterMenu;
organizationsMenu?: OrganizationsFilterMenu;
}>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decouples the main state from the actual component props

The way I think about it is that props are a hack to get JS function to support named arguments and make it mirror HTML more, so you really shouldn't ever have any direct dependencies on that

Copy link
Contributor

@brettkolodny brettkolodny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall lgtm, I think these changes are a lot cleaner but would definitely wait for Kayla's review

Comment on lines +30 to +34
const abortNavigation =
page === newPage || !Number.isFinite(page) || !Number.isInteger(page);
if (abortNavigation) {
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this render optimization? Not re-rendering the page if it's the same page?

@@ -121,9 +106,11 @@ export const templateExamples = () => {
};
};

export const templateVersionRoot: string = "templateVersion";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there seems to be inconsistencies within the code base between using camelCase or UPPER_SNAKE_CASE for file-wide constants so while out of scope for this PR I think deciding on one and sticking to it would be good

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's fair. The JS/TS way seems to be SCREAMING_CASE for constants, while the Go way seems to be to treat them as any other package value

I'm leaning towards switching this to SCREAMING_CASE, but I'll wait for Kayla to chime in

* workspace is in the middle of a transition and will eventually reach a more
* stable state/status.
*/
const ACTIVE_BUILD_STATUSES: readonly WorkspaceStatus[] = [
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This becomes exported in the second PR, which is why I put more of a public-facing comment on it

Comment on lines -128 to -133
// We want to uncheck the selected workspaces always when the url changes
// because of filtering or pagination
// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
useEffect(() => {
setCheckedWorkspaces([]);
}, [urlSearchParams]);
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to comment on this, but the resetChecked logic above is meant to make sure that if we add more URL logic in the future, it doesn't break our filtering and pagination

We were coarsely syncing based on the entire URL params object. That worked fine for now, but if we were to add any logic on the same page that also dealt with URLs and had nothing to do with the filtering/pagination, it would still cause all the checkboxes to get unchecked on every change

Moving the state syncing to the event handlers for the filter/pagination hooks seemed like the better choice. That way, we're also not coupled to the hooks formatting the URLs a specific way either

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants