fix(profile): don't clobber edits with a slow fetch; show decoration load errors

Pronouns, timezone and avatar decoration applied the mount-time fetch
result unconditionally, overwriting a value the user had already edited;
the decoration panel also showed "None" on any fetch failure and let the
user save over a real decoration. Add cancelled/dirty guards (mirroring
ProfileStatus) and an explicit load-error state with Retry.

Fixes #46
Fixes #47

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:06 -04:00
co-authored by Claude Opus 5
parent 32e5fec6f3
commit 6b9c8de393
2 changed files with 98 additions and 15 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -67,24 +67,47 @@ export function ProfileDecoration() {
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
// Distinguish "confirmed no decoration" from "failed to load": a fetch
// failure must not be shown as (and saved over) "None".
const [loadError, setLoadError] = useState(false);
const [loading, setLoading] = useState(true);
// True once the user has picked/cleared a decoration — guards against the
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
useEffect(() => {
const fetchDecoration = useCallback(() => {
let cancelled = false;
setLoading(true);
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
// 404s (a console error for anyone without a decoration). The full profile
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
mx.http
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
if (cancelled) return;
setLoadError(false);
setLoading(false);
if (dirtyRef.current) return;
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
})
.catch(() => {
setCurrent(null);
setSelected(null);
if (cancelled) return;
setLoading(false);
// Do NOT touch current/selected here — a network failure is not proof
// there's no decoration, and defaulting to null risks the user saving
// "None" over a real, still-set decoration (see #46).
setLoadError(true);
});
return () => {
cancelled = true;
};
}, [mx, userId]);
useEffect(() => fetchDecoration(), [fetchDecoration]);
const [saveState, save] = useAsyncCallback(
useCallback(
async (slug: string | null) => {
@@ -105,16 +128,27 @@ export function ProfileDecoration() {
const hasChanges = selected !== current;
const handleSelect = (slug: string) => {
dirtyRef.current = true;
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => setSelected(null);
const handleClear = () => {
dirtyRef.current = true;
setSelected(null);
};
const handleSave = () => {
if (!hasChanges || saving) return;
// Refuse to save while the initial load failed: `current`/`selected` are
// not known-good, so saving could silently overwrite a real decoration.
if (!hasChanges || saving || loadError) return;
save(selected);
};
const handleRetry = () => {
dirtyRef.current = false;
fetchDecoration();
};
return (
<SettingTile
title={
@@ -159,13 +193,15 @@ export function ProfileDecoration() {
</div>
<Box grow="Yes" direction="Column" gap="100">
<Text size="T300">
{selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
{loadError
? 'Failed to load'
: selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
</Text>
{selected && (
{selected && !loadError && (
<Button
type="button"
size="300"
@@ -178,7 +214,7 @@ export function ProfileDecoration() {
</Button>
)}
</Box>
{hasChanges && (
{hasChanges && !loadError && (
<Button
type="button"
size="400"
@@ -194,6 +230,26 @@ export function ProfileDecoration() {
)}
</Box>
{loadError && (
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ color: color.Critical.Main }}>
Could not load your current decoration. Saving is disabled until this succeeds, so you
don’t overwrite it based on a wrong display.
</Text>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleRetry}
disabled={loading}
>
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
</Button>
</Box>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save. Try again.