From 6cb9ddb32d6d74863a08478f6322103330421265 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Thu, 13 Aug 2026 12:47:44 -0500 Subject: [PATCH 01/14] Wizard: let RTK Query own the blueprint name check The wizard asks the server whether a blueprint name is already taken. That answer used to live in a useState inside useDetailsValidation, filled in later by a useEffect that waited 300ms and then called the API. Two things go wrong when you keep server data in useState. First, state belongs to one component. Three components call useDetailsValidation, so there were three copies of the answer, three timers, and three identical requests for the same name. Second, state does not know which request it came from. Type a name, then change it, and two requests are in flight at once. Whichever one finishes last wins, even if it is the older one. The wizard could settle on the answer for a name you had already replaced, and it would stay that way, because nothing re-runs the effect to correct it. Ask the query for the debounced name instead. RTK Query keys its cache by the argument you pass it, so every component asking about the same name shares one cache entry and one request, and an answer for an old name can never overwrite the answer for the current one. While the answer is still on its way, nothing is known to be wrong yet. That is not the same as a validation error, so report it separately as isPending. Before, a pending check made disabledNext true, and the footer reads disabledNext as "there are errors to reveal". It revealed nothing, refused to advance, and left the button enabled. Clicking it did nothing at all. Now the footer disables Next until the answer arrives, so the button is never enabled and inert. Skip the request entirely when the name has not been edited. A name that already belongs to this blueprint cannot collide with anything. --- .../CreateImageWizard/CreateImageWizard.tsx | 3 + .../components/CustomWizardFooter.tsx | 21 ++++- .../utilities/useValidation.tsx | 91 ++++++++++--------- 3 files changed, 68 insertions(+), 47 deletions(-) diff --git a/src/Components/CreateImageWizard/CreateImageWizard.tsx b/src/Components/CreateImageWizard/CreateImageWizard.tsx index f26989158d..5048caaaeb 100644 --- a/src/Components/CreateImageWizard/CreateImageWizard.tsx +++ b/src/Components/CreateImageWizard/CreateImageWizard.tsx @@ -183,6 +183,8 @@ const CreateImageWizard = () => { imagePullValidation.disabledNext || (restrictions.users.isStandalone && usersHaveErrors); + const baseSettingsIsPending = !!detailsValidation.isPending; + const advancedSettingsHasErrors = filesystemValidation.disabledNext || timezoneValidation.disabledNext || @@ -488,6 +490,7 @@ const CreateImageWizard = () => { } diff --git a/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx b/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx index 9c25ac749d..3400f7cc42 100644 --- a/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx +++ b/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx @@ -21,6 +21,7 @@ import { scrollToFirstError } from '../utilities/scrollToFirstError'; type CustomWizardFooterPropType = { disableBack?: boolean; hasErrors: boolean; + isPending?: boolean; beforeNext?: () => boolean; isOnPremise: boolean; }; @@ -28,6 +29,7 @@ type CustomWizardFooterPropType = { export const CustomWizardFooter = ({ disableBack, hasErrors, + isPending, beforeNext, isOnPremise, }: CustomWizardFooterPropType) => { @@ -38,6 +40,13 @@ export const CustomWizardFooter = ({ const reviewAndFinishBtnID = 'wizard-review-and-finish-btn'; const cancelBtnID = 'wizard-cancel-btn'; + // While a check is still running there is no error to show, so clicking would + // do nothing at all. Disable instead: an enabled button that silently + // discards the click is indistinguishable from a broken page, both to a user + // and to anything automating one. Real errors keep their enabled button so + // that clicking still reveals them. + const isWaitingOnValidation = !!isPending && !hasErrors; + const handleNext = () => { if (hasErrors) { flushSync(() => { @@ -89,10 +98,18 @@ export const CustomWizardFooter = ({ > Back - -