- Notifications
You must be signed in to change notification settings - Fork947
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:main
Are you sure you want to change the base?
Uh oh!
There was an error while loading.Please reload this page.
Conversation
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), | ||
}; | ||
}; |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Also added an explicitstring
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
searchParams: URLSearchParams; | ||
onSearchParamsChange: (newParams: URLSearchParams) => void; |
There was a problem hiding this comment.
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
export type UseFilterResult = Readonly<{ | ||
query: string; | ||
values: FilterValues; | ||
used: boolean; | ||
update: (newValues: string | FilterValues) => void; | ||
debounceUpdate: (newValues: string | FilterValues) => void; | ||
cancelDebounce: () => void; | ||
}>; |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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
type UsePaginationResult = Readonly<{ | ||
page: number; | ||
limit: number; | ||
offset: number; | ||
goToPage: (page: number) => void; | ||
}>; |
There was a problem hiding this comment.
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
const abortNavigation = | ||
page === newPage || !Number.isFinite(page) || !Number.isInteger(page); | ||
if (abortNavigation) { | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Another render optimization
There was a problem hiding this comment.
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?
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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 hasObject.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 withObject.is
and numbers, which is why there's the extra===
check
const [suggestedName, setSuggestedName] = useState(() => | ||
generateWorkspaceName(), | ||
); | ||
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName); |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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>, | ||
{}, |
There was a problem hiding this comment.
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
const [checkedWorkspaceIds, setCheckedWorkspaceIds] = useState( | ||
new Set<string>(), | ||
); |
There was a problem hiding this comment.
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>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Slightly re-jiggeredconfirmingBatchAction
@@ -142,7 +164,18 @@ const WorkspacesPage: FC = () => { | |||
canCreateTemplate={permissions.createTemplates} | |||
canChangeVersions={permissions.updateTemplates} | |||
checkedWorkspaces={checkedWorkspaces} | |||
onCheckChange={setCheckedWorkspaces} | |||
onCheckChange={(newWorkspaces) => { | |||
setCheckedWorkspaceIds((current) => { |
There was a problem hiding this comment.
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={() => { |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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
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>; | ||
}>; |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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
type WorkspaceFilterProps = Readonly<{ | ||
filter: UseFilterResult; | ||
error: unknown; | ||
templateMenu: TemplateFilterMenu; | ||
statusMenu: StatusFilterMenu; | ||
userMenu?: UserFilterMenu; | ||
organizationsMenu?: OrganizationsFilterMenu; | ||
}>; |
There was a problem hiding this comment.
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
There was a problem hiding this 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
const abortNavigation = | ||
page === newPage || !Number.isFinite(page) || !Number.isInteger(page); | ||
if (abortNavigation) { | ||
return; | ||
} |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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 usingcamelCase
orUPPER_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
There was a problem hiding this comment.
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[] = [ |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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
// 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]); |
ParkreinerJul 16, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
There was a problem hiding this comment.
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 theresetChecked
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
Uh oh!
There was an error while loading.Please reload this page.
Precursor to#18895
Splitting this off so that the changes are easier to review.
Changes made
withQuery
Storybook decoratoruseFilter
andusePagination
to have much more strict return types, and decoupled them from React Router at the interface leveluseFilter
andusePagination
/workspaces
pageuseEffect
call for syncing URL state to checked workspaces, in favor of explicit state syncsNotes