refactor: simplify publish progress state

This commit is contained in:
Oleg Isonen committed 2026-10-21 10:20:47 +01:00
1 parent 1b532637a7
commit 3d54f6c775
5 files changed
+104 -196

No files matched your search

@@ -1,5 +1,5 @@
import { useStore } from "@nanostores/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Flex,
Tooltip,
@@ -50,35 +50,28 @@ export const DomainCheckbox = (props: DomainCheckboxProps) => {
const { allowStagingPublish, canPublishToStagingOnly } =
useStore($permissions);
const project = useStore($project);
const [selection, setSelection] = useState<{
buildId: string | undefined;
validatedDomains?: readonly string[];
checked: boolean;
}>({
buildId: props.buildId,
validatedDomains: props.validatedDomains,
checked:
props.validatedDomains?.includes(props.domain) ??
props.defaultChecked ??
false,
});
const restoredChecked =
props.validatedDomains?.includes(props.domain) ??
props.defaultChecked ??
false;
const [selected, setSelected] = useState(restoredChecked);
const previous = useRef({
buildId: props.buildId,
validatedDomains: props.validatedDomains,
});
const hasNewValidation =
props.validatedDomains !== undefined &&
selection.validatedDomains !== props.validatedDomains;
const hasNewBuild = selection.buildId !== props.buildId;
previous.current.validatedDomains !== props.validatedDomains;
const hasNewBuild = previous.current.buildId !== props.buildId;
useEffect(() => {
if (hasNewValidation || hasNewBuild) {
setSelection({
buildId: props.buildId,
validatedDomains: props.validatedDomains,
checked: restoredChecked,
});
setSelected(restoredChecked);
}
previous.current = {
buildId: props.buildId,
validatedDomains: props.validatedDomains,
};
}, [
hasNewValidation,
hasNewBuild,
@@ -139,7 +132,7 @@ export const DomainCheckbox = (props: DomainCheckboxProps) => {
? false
: hasNewValidation || hasNewBuild
? restoredChecked
: selection.checked;
: selected;
const disabled = isRestricted ? true : props.disabled;
const hideDomainCheckbox =
@@ -154,13 +147,7 @@ export const DomainCheckbox = (props: DomainCheckboxProps) => {
disabled={disabled}
key={props.buildId ?? "-"}
checked={hideDomainCheckbox || checked}
onCheckedChange={(value) =>
setSelection({
buildId: props.buildId,
validatedDomains: props.validatedDomains,
checked: value === true,
})
}
onCheckedChange={(value) => setSelected(value === true)}
css={{ pointerEvents: "all" }}
name={domainToPublishName}
value={props.domain}
@@ -142,11 +142,12 @@ test("clears the validation result when the publish popover closes", async () =>
test.each([
{ destination: "none" as const, expectedToasts: 1 },
{ destination: "none" as const, reopen: true, expectedToasts: 0 },
{ destination: "export" as const, expectedToasts: 1 },
{ destination: "publish" as const, expectedToasts: 0 },
])(
"reports validation success after switching to $destination",
async ({ destination, expectedToasts }) => {
"reports validation success after switching to $destination (reopen: $reopen)",
async ({ destination, reopen, expectedToasts }) => {
const successToast = vi
.spyOn(toast, "success")
.mockImplementation(() => "");
@@ -182,6 +183,9 @@ test.each([
<button type="button" onClick={() => setDialog(destination)}>
Switch
</button>
<button type="button" onClick={() => setDialog("publish")}>
Reopen
</button>
<output>{validationState}</output>
</TooltipProvider>
);
@@ -191,9 +195,13 @@ test.each([
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(<Harness />));
const [validate, , switchDialog] = container.querySelectorAll("button");
const [validate, , switchDialog, reopenDialog] =
container.querySelectorAll("button");
act(() => validate.click());
act(() => switchDialog.click());
if (reopen) {
act(() => reopenDialog.click());
}
await act(async () => {
finishValidation();
await validation;
@@ -201,7 +209,7 @@ test.each([
expect(successToast).toHaveBeenCalledTimes(expectedToasts);
expect(container.querySelector("output")?.textContent).toBe(
destination === "publish" ? "passed" : "idle"
destination === "publish" || reopen ? "passed" : "idle"
);
}
);
@@ -9,33 +9,24 @@ export const usePublishValidationState = (
) => {
const [validationState, setValidationState] =
useState<PublishValidationState>("idle");
const session = useRef(0);
const sessionAtRender = session.current;
const dialogRef = useRef(dialog);
dialogRef.current = dialog;
const reset = useCallback(() => {
session.current += 1;
setValidationState("idle");
}, []);
const update = useCallback(
(state: PublishValidationState) => {
if (state === "passed" && dialogRef.current !== "publish") {
toast.success("Validation passed. Ready to publish.", {
duration: Number.POSITIVE_INFINITY,
});
return;
}
if (
dialogRef.current === "publish" &&
session.current === sessionAtRender
) {
setValidationState(state);
}
},
[sessionAtRender]
);
const update = useCallback((state: PublishValidationState) => {
if (state === "passed" && dialogRef.current !== "publish") {
toast.success("Validation passed. Ready to publish.", {
duration: Number.POSITIVE_INFINITY,
});
return;
}
if (dialogRef.current === "publish") {
setValidationState(state);
}
}, []);
useEffect(() => {
if (dialog !== "publish") {
@@ -13,7 +13,6 @@ import { PublishValidationResults } from "./publish-validation-results";
const {
usePublishProgress,
waitForPublishStatus,
waitForOwnedPublishStatus,
refreshUntilSuccess,
useManualValidationFindings,
getRefreshedProject,
@@ -378,11 +377,15 @@ test("an old poll cannot update a newly mounted copy of the same project", async
const query = new Promise<{ success: true; project: Project }>((resolve) => {
finishQuery = resolve;
});
const polling = waitForOwnedPublishStatus({
projectId: "project",
isCurrentProject: progress!.isCurrentProject,
query: () => query,
});
const polling = waitForPublishStatus(
() =>
getRefreshedProject({
projectId: "project",
isCurrent: progress!.isCurrentProject,
query: () => query,
}),
progress!.isCurrentProject
);
act(() => root?.unmount());
$project.set({ id: "another-project" } as Project);
@@ -503,7 +506,7 @@ test("a rejected publish request shows an inline error while the dialog is open"
>
Start
</button>
<output>{progress.requestError}</output>
<output>{progress.publishError}</output>
</>
);
};
@@ -538,7 +541,7 @@ test("starting validation clears a previous request failure", async () => {
Publish
</button>
<button onClick={progress.clearErrors}>Validate</button>
<output>{progress.publishError ?? progress.requestError}</output>
<output>{progress.publishError}</output>
</>
);
};
@@ -570,7 +573,7 @@ test("a rejected publish request shows a toast after the dialog closes", async (
return (
<>
<button onClick={() => progress.start(() => publish)}>Start</button>
<output>{progress.requestError}</output>
<output>{progress.publishError}</output>
</>
);
};
@@ -621,7 +624,7 @@ test("a rejected publish request appears in the panel reopened before rejection"
>
Reopen
</button>
{isOpen && <output>{progress.requestError}</output>}
{isOpen && <output>{progress.publishError}</output>}
</>
);
};
@@ -868,7 +871,7 @@ test("a newer build clears a recovered transport error", async () => {
>
Refresh
</button>
<output>{progress.requestError}</output>
<output>{progress.publishError}</output>
</>
);
};
@@ -923,7 +926,7 @@ test("a recovered failed build replaces a lost response error", async () => {
>
Refresh
</button>
<output>{progress.publishError ?? progress.requestError}</output>
<output>{progress.publishError}</output>
</>
);
};
@@ -124,11 +124,7 @@ import {
PublishValidationResults,
type PublishValidationFinding,
} from "./publish-validation-results";
import {
PublishActions,
usePublishValidationState,
type PublishValidationState,
} from "./publish-actions";
import { PublishActions, usePublishValidationState } from "./publish-actions";
import { flushExternalContentProject } from "~/shared/external-content-roots";
import { getPublishValidationErrorMessage } from "./publish-error";
import { runPublishAfterBestEffortChecks } from "./publish-preflight";
@@ -581,14 +577,13 @@ const usePublishProgress = (projectId: Project["id"]) => {
const [startedAt, setStartedAt] = useState<number>();
const [showCountdown, setShowCountdown] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [requestError, setRequestError] = useState<string>();
const [publishFindings, setPublishFindings] = useState<
PublishValidationFinding[]
>([]);
const publishDiagnosticsSession = useRef(0);
const [publishErrorState, setPublishErrorState] = useState<{
value: JSX.Element | string;
source: "request" | "status";
source: "request" | "transport" | "status";
}>();
const [now, setNow] = useState(Date.now);
const isMounted = useRef(false);
@@ -634,7 +629,6 @@ const usePublishProgress = (projectId: Project["id"]) => {
}, []);
const clearErrors = useCallback(() => {
setRequestError(undefined);
setPublishErrorState(undefined);
}, []);
@@ -665,16 +659,15 @@ const usePublishProgress = (projectId: Project["id"]) => {
build.buildId !== requestBaselineBuildId.current;
if (status === "FAILED") {
if (hasStartedPublishRequest.current && isNewBuild) {
setRequestError(undefined);
setPublishErrorState({ value: statusText, source: "status" });
}
} else {
setPublishErrorState((current) =>
current?.source === "status" ? undefined : current
current?.source === "status" ||
(isNewBuild && current?.source === "transport")
? undefined
: current
);
if (isNewBuild) {
setRequestError(undefined);
}
}
}, []);
@@ -734,14 +727,14 @@ const usePublishProgress = (projectId: Project["id"]) => {
setNow(startedAt);
setShowCountdown(true);
setIsPublishing(true);
setRequestError(undefined);
setPublishErrorState(undefined);
publish()
.catch((error) => {
console.error(error);
const message =
error instanceof Error ? error.message : "Unknown error";
if (isCurrentProject()) {
setRequestError(message);
setPublishErrorState({ value: message, source: "transport" });
}
if ($publishDialog.get() !== "publish" || !isCurrentProject()) {
toast.error(message);
@@ -753,7 +746,6 @@ const usePublishProgress = (projectId: Project["id"]) => {
return {
manualValidationFindings,
isPublishing,
requestError,
publishFindings,
setPublishFindings,
beginPublishDiagnostics,
@@ -811,21 +803,6 @@ const waitForPublishStatus = async (
}
};
const waitForOwnedPublishStatus = ({
projectId,
isCurrentProject,
query,
}: {
projectId: Project["id"];
isCurrentProject: () => boolean;
query?: () => ReturnType<typeof nativeClient.domain.project.query>;
}) =>
waitForPublishStatus(
() =>
getRefreshedProject({ projectId, isCurrent: isCurrentProject, query }),
isCurrentProject
);
const refreshUntilSuccess = async (
refresh: (showError: boolean) => Promise<Project | undefined>,
isOpen: () => boolean,
@@ -963,45 +940,21 @@ const Publish = ({
disabled,
refresh,
restrictedFeatures,
validationState,
onValidationStateChange,
isPublishing,
requestError,
manualValidationFindings,
publishFindings,
beginPublishDiagnostics,
publishError,
setPublishError,
setPublishStatusError,
clearErrors,
isCurrentProject,
countdown,
startPublishing,
validation,
progress,
}: {
project: Project;
timesLeft: number;
disabled: boolean;
refresh: () => Promise<void>;
restrictedFeatures: Map<string, RestrictedFeature>;
validationState: PublishValidationState;
onValidationStateChange: (state: PublishValidationState) => void;
isPublishing: boolean;
requestError: string | undefined;
manualValidationFindings: ReturnType<typeof useManualValidationFindings>;
publishFindings: PublishValidationFinding[];
beginPublishDiagnostics: ReturnType<
typeof usePublishProgress
>["beginPublishDiagnostics"];
publishError: JSX.Element | string | undefined;
setPublishError: (error: JSX.Element | string | undefined) => void;
setPublishStatusError: (error: JSX.Element | string) => void;
clearErrors: () => void;
isCurrentProject: () => boolean;
countdown: number | undefined;
startPublishing: (publish: () => Promise<void>) => void;
validation: ReturnType<typeof usePublishValidationState>;
progress: ReturnType<typeof usePublishProgress>;
}) => {
const validationFindings = progress.manualValidationFindings;
const { countdown, isCurrentProject } = progress;
const { validationState, update: onValidationStateChange } = validation;
const { userPublishCount, maxDailyPublishesPerUser } = useUserPublishCount();
const validationFindings = manualValidationFindings;
const { invalidate: invalidateValidationFindings } = validationFindings;
const buttonRef = useRef<HTMLButtonElement>(null);
const [hasSelectedDomains, setHasSelectedDomains] = useState(false);
@@ -1011,7 +964,7 @@ const Publish = ({
const hasPendingState = project.latestBuildVirtual
? getPublishStatusAndText(project.latestBuildVirtual).status === "PENDING"
: false;
const isPublishInProgress = isPublishing || hasPendingState;
const isPublishInProgress = progress.isPublishing || hasPendingState;
useEffect(() => {
const form = buttonRef.current?.closest("form");
@@ -1108,11 +1061,13 @@ const Publish = ({
</>
);
}
setPublishError(error);
if (publishResult.error === "NOT_IMPLEMENTED") {
toast.info(error);
} else {
toast.error(error);
progress.setPublishError(error);
if ($publishDialog.get() !== "publish" || !isCurrentProject()) {
if (publishResult.error === "NOT_IMPLEMENTED") {
toast.info(error);
} else {
toast.error(error);
}
}
if (process.env.NODE_ENV === "development") {
@@ -1123,15 +1078,19 @@ const Publish = ({
return;
}
const result = await waitForOwnedPublishStatus({
projectId: project.id,
isCurrentProject,
});
const result = await waitForPublishStatus(
() =>
getRefreshedProject({
projectId: project.id,
isCurrent: isCurrentProject,
}),
isCurrentProject
);
reportPublishResult({
result,
timesLeft,
maxDailyPublishesPerUser,
setPublishError: setPublishStatusError,
setPublishError: progress.setPublishStatusError,
isCurrentProject,
});
};
@@ -1144,8 +1103,8 @@ const Publish = ({
const handleValidate = async (formData: FormData) => {
const domains = getDomainsFromForm(formData);
const session = validationFindings.begin(domains);
clearErrors();
beginPublishDiagnostics();
progress.clearErrors();
progress.beginPublishDiagnostics();
if (domains.length === 0) {
toast.error("Please select at least one domain to publish");
return;
@@ -1175,8 +1134,8 @@ const Publish = ({
const handlePublish = (formData: FormData) => {
validationFindings.invalidate();
clearErrors();
const diagnostics = beginPublishDiagnostics();
progress.clearErrors();
const diagnostics = progress.beginPublishDiagnostics();
// Custom domain checkboxes are disabled on free plan so they are never
// submitted — only the staging (wstd.io) domain can appear in formData.
@@ -1192,7 +1151,7 @@ const Publish = ({
return;
}
startPublishing(() =>
progress.start(() =>
runPublishAfterBestEffortChecks({
checks: async () => {
const checks = await runPublishDiagnostics(project.id);
@@ -1217,10 +1176,10 @@ const Publish = ({
return (
<Flex gap={2} shrink={false} direction={"column"}>
<PublishValidationResults
findings={[...validationFindings.findings, ...publishFindings]}
findings={[...validationFindings.findings, ...progress.publishFindings]}
/>
{(publishError ?? requestError) && (
<Text color="destructive">{publishError ?? requestError}</Text>
{progress.publishError && (
<Text color="destructive">{progress.publishError}</Text>
)}
<PublishActions
publishButtonRef={buttonRef}
@@ -1612,22 +1571,8 @@ const UpgradeBanner = ({ hasCustomDomains }: { hasCustomDomains: boolean }) => {
const Content = (props: {
projectId: Project["id"];
onExportClick: () => void;
validationState: PublishValidationState;
onValidationStateChange: (state: PublishValidationState) => void;
isPublishing: boolean;
requestError: string | undefined;
manualValidationFindings: ReturnType<typeof useManualValidationFindings>;
publishFindings: PublishValidationFinding[];
beginPublishDiagnostics: ReturnType<
typeof usePublishProgress
>["beginPublishDiagnostics"];
publishError: JSX.Element | string | undefined;
setPublishError: (error: JSX.Element | string | undefined) => void;
setPublishStatusError: (error: JSX.Element | string) => void;
clearErrors: () => void;
isCurrentProject: () => boolean;
countdown: number | undefined;
startPublishing: (publish: () => Promise<void>) => void;
validation: ReturnType<typeof usePublishValidationState>;
progress: ReturnType<typeof usePublishProgress>;
}) => {
const restrictedFeatures = useStore($restrictedFeatures);
const [newDomains, setNewDomains] = useState(new Set<string>());
@@ -1700,7 +1645,7 @@ const Content = (props: {
refresh={refreshProject}
projectState={projectState}
project={project}
validatedDomains={props.manualValidationFindings.domains}
validatedDomains={props.progress.manualValidationFindings.domains}
/>
<Domains
@@ -1708,7 +1653,7 @@ const Content = (props: {
domains={project.domainsVirtual}
refresh={refreshProject}
project={project}
validatedDomains={props.manualValidationFindings.domains}
validatedDomains={props.progress.manualValidationFindings.domains}
/>
</RadioGroup>
</ScrollArea>
@@ -1745,20 +1690,8 @@ const Content = (props: {
timesLeft={maxDailyPublishesPerUser - userPublishCount}
disabled={false}
restrictedFeatures={restrictedFeatures}
validationState={props.validationState}
onValidationStateChange={props.onValidationStateChange}
isPublishing={props.isPublishing}
requestError={props.requestError}
manualValidationFindings={props.manualValidationFindings}
publishFindings={props.publishFindings}
beginPublishDiagnostics={props.beginPublishDiagnostics}
publishError={props.publishError}
setPublishError={props.setPublishError}
setPublishStatusError={props.setPublishStatusError}
clearErrors={props.clearErrors}
isCurrentProject={props.isCurrentProject}
countdown={props.countdown}
startPublishing={props.startPublishing}
validation={props.validation}
progress={props.progress}
/>
</PanelContent>
</form>
@@ -1986,8 +1919,7 @@ type PublishProps = {
export const PublishButton = ({ projectId }: PublishProps) => {
const publishDialog = useStore($publishDialog);
const authTokenPermissions = useStore($authTokenPermissions);
const { validationState, update, reset } =
usePublishValidationState(publishDialog);
const validation = usePublishValidationState(publishDialog);
const publishProgress = usePublishProgress(projectId);
const {
dismissCountdown,
@@ -2057,14 +1989,14 @@ export const PublishButton = ({ projectId }: PublishProps) => {
: "Only the owner, an admin, or content editors with publish permissions can publish projects";
const handleExportClick = () => {
reset();
validation.reset();
publishProgress.dismissCountdown();
$publishDialog.set("export");
};
const handleOpenChange = (isOpen: boolean) => {
if (isOpen === false) {
reset();
validation.reset();
publishProgress.dismissCountdown();
}
$publishDialog.set(isOpen ? "publish" : "none");
@@ -2106,20 +2038,8 @@ export const PublishButton = ({ projectId }: PublishProps) => {
<Content
projectId={projectId}
onExportClick={handleExportClick}
validationState={validationState}
onValidationStateChange={update}
isPublishing={publishProgress.isPublishing}
requestError={publishProgress.requestError}
manualValidationFindings={publishProgress.manualValidationFindings}
publishFindings={publishProgress.publishFindings}
beginPublishDiagnostics={publishProgress.beginPublishDiagnostics}
publishError={publishProgress.publishError}
setPublishError={publishProgress.setPublishError}
setPublishStatusError={publishProgress.setPublishStatusError}
clearErrors={publishProgress.clearErrors}
isCurrentProject={publishProgress.isCurrentProject}
countdown={publishProgress.countdown}
startPublishing={publishProgress.start}
validation={validation}
progress={publishProgress}
/>
)}
</PopoverContent>
@@ -2131,7 +2051,6 @@ export const __testing__ = {
usePublishProgress,
useManualValidationFindings,
waitForPublishStatus,
waitForOwnedPublishStatus,
refreshUntilSuccess,
getRefreshedProject,
reportPublishResult,