fix: prevent page jump when adding/removing tags with optimistic updates

- Update UI immediately when adding/removing tags without full page reload
- Fetch updated recipe data in background to get proper tag IDs
- Revert optimistic update on error and reload
- Maintains scroll position and focus in tag input field

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul R Kartchner
2026-01-17 00:06:00 -07:00
parent 022d0c9529
commit d87210f8d3

View File

@@ -141,24 +141,35 @@ function RecipeDetail() {
return;
}
// Optimistically update the UI immediately
const optimisticTag = { tag: { id: 'temp', name: trimmedTag } };
setRecipe({
...recipe,
tags: [...(recipe.tags || []), optimisticTag]
});
setTagInput('');
// Keep focus in input field
setTimeout(() => tagInputRef.current?.focus(), 0);
try {
setSavingTags(true);
// Send array of tag names (strings) to API
const updatedTags = [...existingTagNames, trimmedTag];
await recipesApi.update(id, { tags: updatedTags });
// Reload the recipe to get the updated tag structure from API
await loadRecipe(id);
setTagInput('');
// Reload available tags to include newly created ones
loadTags();
// Keep focus in input field
setTimeout(() => tagInputRef.current?.focus(), 0);
// Fetch the updated recipe to get the proper tag IDs, but don't reload the whole page
const response = await recipesApi.getById(id);
if (response.data) {
setRecipe(response.data);
}
} catch (err) {
console.error('Failed to add tag:', err);
alert('Failed to add tag');
// Revert optimistic update on error
await loadRecipe(id);
} finally {
setSavingTags(false);
}
@@ -167,10 +178,21 @@ function RecipeDetail() {
const handleRemoveTag = async (tagToRemove: string) => {
if (!id || !recipe) return;
// Optimistically update the UI immediately
const previousTags = recipe.tags;
const updatedTagsOptimistic = (recipe.tags || []).filter(tagItem => {
const tagName = typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name;
return tagName !== tagToRemove;
});
setRecipe({
...recipe,
tags: updatedTagsOptimistic
});
try {
setSavingTags(true);
// Convert existing tags to string array and filter out the removed tag
const existingTagNames = (recipe.tags || [])
const existingTagNames = (previousTags || [])
.map(tagItem =>
typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name
)
@@ -178,11 +200,16 @@ function RecipeDetail() {
const updatedTags = existingTagNames.filter(tag => tag !== tagToRemove);
await recipesApi.update(id, { tags: updatedTags });
// Reload the recipe to get the updated tag structure from API
await loadRecipe(id);
// Fetch the updated recipe to get the proper tag structure
const response = await recipesApi.getById(id);
if (response.data) {
setRecipe(response.data);
}
} catch (err) {
console.error('Failed to remove tag:', err);
alert('Failed to remove tag');
// Revert optimistic update on error
await loadRecipe(id);
} finally {
setSavingTags(false);
}