Skip to content

Commit fd8f323

Browse files
committed
fix(webapp): surface customize sidebar save failures instead of closing silently
A confirmed customization could silently vanish: the modal closed the moment Confirm was clicked, so a save that failed or hung (dev server hiccups, transaction timeouts) looked identical to a successful one, and a thrown preferences write escalated to the app error boundary. Confirm now spins until the save response and the refreshed side menu data land, the dialog stays open with an inline error on failure, the preference routes report failures as responses instead of throwing, and the preferences transaction retries retriable Prisma errors.
1 parent 9376d9a commit fd8f323

5 files changed

Lines changed: 145 additions & 52 deletions

File tree

apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,21 @@ export function CustomizeSidebarDialog({
113113
sections,
114114
prefs,
115115
onConfirm,
116-
onClose,
116+
isConfirming,
117+
confirmError,
117118
}: {
118119
sections: CustomizeSidebarSection[];
119120
prefs: SavedPreferences | undefined;
120-
/** Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. */
121+
/**
122+
* Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. The
123+
* parent submits the payload and closes the dialog once the save lands (or reports back via
124+
* `confirmError`), so a failed save never silently reads as a successful one.
125+
*/
121126
onConfirm: (payload: SidebarCustomizationPayload) => void;
122-
onClose: () => void;
127+
/** True from Confirm until the save (and the refreshed side menu data) lands. */
128+
isConfirming: boolean;
129+
/** Save failure to surface next to Confirm; the dialog stays open for a retry. */
130+
confirmError?: string;
123131
}) {
124132
const [state, setState] = useState<DialogState>(() => buildState(sections, prefs));
125133

@@ -243,7 +251,6 @@ export function CustomizeSidebarDialog({
243251
};
244252

245253
onConfirm(payload);
246-
onClose();
247254
};
248255

249256
return (
@@ -300,9 +307,19 @@ export function CustomizeSidebarDialog({
300307
Reset
301308
</Button>
302309
</div>
303-
<Button variant="primary/medium" onClick={confirm} disabled={hasBlankLabels}>
304-
Confirm
305-
</Button>
310+
<div className="flex min-w-0 items-center gap-3">
311+
{confirmError && !isConfirming && (
312+
<FormError className="truncate">{confirmError}</FormError>
313+
)}
314+
<Button
315+
variant="primary/medium"
316+
onClick={confirm}
317+
disabled={hasBlankLabels}
318+
isLoading={isConfirming}
319+
>
320+
Confirm
321+
</Button>
322+
</div>
306323
</DialogFooter>
307324
</DialogContent>
308325
);

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ExclamationTriangleIcon,
55
} from "@heroicons/react/24/outline";
66
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
7-
import { useFetcher, useNavigation, useSubmit } from "@remix-run/react";
7+
import { useFetcher, useNavigation, useRevalidator, useSubmit } from "@remix-run/react";
88
import { LayoutGroup, motion } from "framer-motion";
99
import {
1010
type CSSProperties,
@@ -395,15 +395,51 @@ export function SideMenu({
395395
const isV3Project = project.engine === "V1";
396396
const favorites = useFavorites();
397397
const [isCustomizeOpen, setCustomizeOpen] = useState(false);
398-
// Lives here (not in the dialog): confirming closes/unmounts the dialog, which would abort a
399-
// fetcher owned by it before the request fires.
400-
const customizationFetcher = useFetcher();
398+
// Lives here (not in the dialog): the dialog unmounts on close, which would abort a fetcher it
399+
// owned mid-request.
400+
const customizationFetcher = useFetcher<{ success: boolean }>();
401+
const revalidator = useRevalidator();
402+
// Confirm lifecycle: the dialog stays open (Confirm spinning) until the save response AND the
403+
// revalidated preferences land, so the side menu is already updated the moment it closes — and
404+
// a failed or hung save shows as such instead of silently reading as success.
405+
const [isCustomizeConfirmPending, setCustomizeConfirmPending] = useState(false);
406+
const [customizeError, setCustomizeError] = useState<string>();
407+
// The fetcher's data survives across confirms, so only settle once THIS submission has been
408+
// seen in flight — otherwise a reopened dialog could consume the previous confirm's response.
409+
const customizeSubmitSeenRef = useRef(false);
401410
const submitSidebarCustomization = (payload: SidebarCustomizationPayload) => {
411+
setCustomizeError(undefined);
412+
setCustomizeConfirmPending(true);
413+
customizeSubmitSeenRef.current = false;
402414
customizationFetcher.submit(
403415
{ customization: JSON.stringify(payload) },
404416
{ method: "POST", action: "/resources/preferences/sidemenu" }
405417
);
406418
};
419+
useEffect(() => {
420+
if (!isCustomizeConfirmPending) return;
421+
if (customizationFetcher.state !== "idle") {
422+
customizeSubmitSeenRef.current = true;
423+
return;
424+
}
425+
if (!customizeSubmitSeenRef.current) return; // submit hasn't been picked up yet
426+
const data = customizationFetcher.data;
427+
if (!data) return;
428+
if (data.success) {
429+
// Wait out the post-save revalidation so the menu behind the dialog reflects the changes
430+
if (revalidator.state !== "idle") return;
431+
setCustomizeConfirmPending(false);
432+
setCustomizeOpen(false);
433+
} else {
434+
setCustomizeConfirmPending(false);
435+
setCustomizeError("Couldn't save your changes. Please try again.");
436+
}
437+
}, [
438+
isCustomizeConfirmPending,
439+
customizationFetcher.state,
440+
customizationFetcher.data,
441+
revalidator.state,
442+
]);
407443
// Same ownership rule: removing a favorite optimistically unmounts its menu item (and, for the
408444
// last favorite, the whole section), which would abort an item-owned fetcher mid-request.
409445
// Separate fetchers per mutation: fetchers are single-flight, so a shared one would cancel an
@@ -1221,7 +1257,18 @@ export function SideMenu({
12211257
</motion.div>
12221258
</div>
12231259
</div>
1224-
<Dialog open={isCustomizeOpen} onOpenChange={setCustomizeOpen}>
1260+
<Dialog
1261+
open={isCustomizeOpen}
1262+
onOpenChange={(open) => {
1263+
setCustomizeOpen(open);
1264+
if (!open) {
1265+
// Cancel/ESC during a pending confirm abandons the wait; a still-running save is
1266+
// harmless (the menu revalidates whenever it lands)
1267+
setCustomizeConfirmPending(false);
1268+
setCustomizeError(undefined);
1269+
}
1270+
}}
1271+
>
12251272
{/* Mounted only while open so the modal state re-seeds from current preferences each time */}
12261273
{isCustomizeOpen && (
12271274
<CustomizeSidebarDialog
@@ -1232,7 +1279,8 @@ export function SideMenu({
12321279
sectionItemOrder: sideMenuPrefs?.sectionItemOrder,
12331280
}}
12341281
onConfirm={submitSidebarCustomization}
1235-
onClose={() => setCustomizeOpen(false)}
1282+
isConfirming={isCustomizeConfirmPending}
1283+
confirmError={customizeError}
12361284
/>
12371285
)}
12381286
</Dialog>

apps/webapp/app/routes/resources.preferences.favorites.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
removeFavorite,
66
renameFavorite,
77
} from "~/services/dashboardPreferences.server";
8+
import { logger } from "~/services/logger.server";
89
import { requireUser } from "~/services/session.server";
910

1011
const FavoriteLabel = z
@@ -52,20 +53,27 @@ export async function action({ request }: ActionFunctionArgs) {
5253
return json({ success: false, error: "Invalid request data" }, { status: 400 });
5354
}
5455

55-
switch (result.data.intent) {
56-
case "add": {
57-
const { id, url, label, icon } = result.data;
58-
await addFavorite({ user, favorite: { id, url, label, icon } });
59-
break;
60-
}
61-
case "remove": {
62-
await removeFavorite({ user, id: result.data.id });
63-
break;
64-
}
65-
case "rename": {
66-
await renameFavorite({ user, id: result.data.id, label: result.data.label });
67-
break;
56+
// Errors come back as a response (never a throw, which would escalate a preferences write to
57+
// the error boundary); the side menu's optimistic entries revert when the fetcher settles.
58+
try {
59+
switch (result.data.intent) {
60+
case "add": {
61+
const { id, url, label, icon } = result.data;
62+
await addFavorite({ user, favorite: { id, url, label, icon } });
63+
break;
64+
}
65+
case "remove": {
66+
await removeFavorite({ user, id: result.data.id });
67+
break;
68+
}
69+
case "rename": {
70+
await renameFavorite({ user, id: result.data.id, label: result.data.label });
71+
break;
72+
}
6873
}
74+
} catch (error) {
75+
logger.error("Failed to update favorites", { error: String(error) });
76+
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
6977
}
7078

7179
return json({ success: true });

apps/webapp/app/routes/resources.preferences.sidemenu.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
updateSideMenuCustomization,
1010
updateSideMenuPreferences,
1111
} from "~/services/dashboardPreferences.server";
12+
import { logger } from "~/services/logger.server";
1213
import { requireUser } from "~/services/session.server";
1314

1415
// Transforms form data string "true"/"false" to boolean, or undefined if not present
@@ -67,14 +68,25 @@ export async function action({ request }: ActionFunctionArgs) {
6768
}
6869
const { sectionOrder, hiddenItems, sectionItemOrder, favorites, removedFavoriteIds } =
6970
customizationResult.data;
70-
await updateSideMenuCustomization({
71-
user,
72-
sectionOrder,
73-
hiddenItems,
74-
sectionItemOrder,
75-
favorites,
76-
removedFavoriteIds,
77-
});
71+
// The modal keeps its "Confirm" pending until this responds, so failures must come back as a
72+
// response (never a throw, which would escalate a preferences write to the error boundary).
73+
try {
74+
const updated = await updateSideMenuCustomization({
75+
user,
76+
sectionOrder,
77+
hiddenItems,
78+
sectionItemOrder,
79+
favorites,
80+
removedFavoriteIds,
81+
});
82+
// undefined means nothing was written (impersonating, or the user row is gone)
83+
if (!updated) {
84+
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
85+
}
86+
} catch (error) {
87+
logger.error("Failed to save sidebar customization", { error: String(error) });
88+
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
89+
}
7890
return json({ success: true });
7991
}
8092

apps/webapp/app/services/dashboardPreferences.server.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,36 @@ async function mutateDashboardPreferences(
9393
userId: string,
9494
mutate: (current: DashboardPreferences) => DashboardPreferences | undefined
9595
) {
96-
return await $transaction(prisma, "mutateDashboardPreferences", async (tx) => {
97-
const rows = await tx.$queryRaw<Array<{ dashboardPreferences: unknown }>>`
96+
return await $transaction(
97+
prisma,
98+
"mutateDashboardPreferences",
99+
async (tx) => {
100+
const rows = await tx.$queryRaw<Array<{ dashboardPreferences: unknown }>>`
98101
SELECT "dashboardPreferences" FROM "User" WHERE id = ${userId} FOR UPDATE
99102
`;
100-
if (rows.length === 0) {
101-
return undefined;
102-
}
103+
if (rows.length === 0) {
104+
return undefined;
105+
}
103106

104-
const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences));
105-
if (!updated) {
106-
return undefined;
107-
}
107+
const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences));
108+
if (!updated) {
109+
return undefined;
110+
}
108111

109-
return await tx.user.update({
110-
where: {
111-
id: userId,
112-
},
113-
data: {
114-
dashboardPreferences: updated,
115-
},
116-
});
117-
});
112+
return await tx.user.update({
113+
where: {
114+
id: userId,
115+
},
116+
data: {
117+
dashboardPreferences: updated,
118+
},
119+
});
120+
},
121+
// Concurrent writers queue on the row lock, so under load (several debounced writes plus a
122+
// revalidation burst) a transaction can time out acquiring a connection or the lock; those
123+
// codes are retriable and preference writes are idempotent.
124+
{ maxRetries: 3 }
125+
);
118126
}
119127

120128
export async function updateCurrentProjectEnvironmentId({

0 commit comments

Comments
 (0)