-
Notifications
You must be signed in to change notification settings - Fork 2
feat(dashboard): auto-save config with 500ms debounce #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5210f9b
eb894ad
b8300bd
af6ff5c
61d6f1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Loader2, Save } from 'lucide-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AlertCircle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { toast } from 'sonner'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -136,6 +136,15 @@ export function ConfigEditor() { | |||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const abortRef = useRef<AbortController | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Auto-save status indicator. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Debounce timer for auto-save. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /** True while the initial config load is in progress — suppresses auto-save. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const isLoadingConfigRef = useRef(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Ref tracking latest draftConfig for race-condition detection during save. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const draftConfigRef = useRef<GuildConfig | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig)); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -169,7 +178,7 @@ export function ConfigEditor() { | |||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // ── Load config when guild changes ───────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const fetchConfig = useCallback(async (id: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const fetchConfig = useCallback(async (id: string, { skipSaveStatusReset = false } = {}) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!id) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| abortRef.current?.abort(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -178,6 +187,7 @@ export function ConfigEditor() { | |||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| setLoading(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| isLoadingConfigRef.current = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -209,10 +219,19 @@ export function ConfigEditor() { | |||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setSavedConfig(data); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setDraftConfig(structuredClone(data)); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Reset status after a successful reload; clear any stale error | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!skipSaveStatusReset) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setSaveStatus('idle'); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Use a macrotask so the state setter for draftConfig fires before we clear the flag | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setTimeout(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| isLoadingConfigRef.current = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, 0); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ((err as Error).name === 'AbortError') return; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| isLoadingConfigRef.current = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+226
to
+234
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // Use a macrotask so the state setter for draftConfig fires before we clear the flag | |
| setTimeout(() => { | |
| isLoadingConfigRef.current = false; | |
| }, 0); | |
| setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); | |
| setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); | |
| } catch (err) { | |
| if ((err as Error).name === 'AbortError') return; | |
| isLoadingConfigRef.current = false; | |
| // Use a macrotask so the state setter for draftConfig fires before we clear the flag. | |
| // Only clear the loading flag if this request is still the active one. | |
| setTimeout(() => { | |
| if (abortRef.current === controller) { | |
| isLoadingConfigRef.current = false; | |
| } | |
| }, 0); | |
| setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); | |
| setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', ')); | |
| } catch (err) { | |
| if ((err as Error).name === 'AbortError') return; | |
| // Only clear the loading flag if this request is still the active one. | |
| if (abortRef.current === controller) { | |
| isLoadingConfigRef.current = false; | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ref update timing issue: draftConfigRef.current is updated asynchronously in this effect after renders, but saveChanges (line 389) checks it synchronously after PATCH completes. If the server responds before this effect runs (e.g., user types → render scheduled → PATCH completes → ref still has old value), the check incorrectly concludes no changes occurred, calls fetchConfig, and overwrites the user's pending edits.
Consider updating the ref synchronously when setDraftConfig is called, or use a different approach like comparing against the latest state in a ref callback.
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/components/dashboard/config-editor.tsx
Line: 248-251
Comment:
ref update timing issue: `draftConfigRef.current` is updated asynchronously in this effect after renders, but `saveChanges` (line 389) checks it synchronously after PATCH completes. If the server responds before this effect runs (e.g., user types → render scheduled → PATCH completes → ref still has old value), the check incorrectly concludes no changes occurred, calls `fetchConfig`, and overwrites the user's pending edits.
Consider updating the ref synchronously when `setDraftConfig` is called, or use a different approach like comparing against the latest state in a ref callback.
How can I resolve this? If you propose a fix, please make it concise.
Copilot
AI
Mar 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On full success, this sets status to saved and then calls fetchConfig, which toggles the global loading state and re-fetches the entire config. With auto-save, this can introduce frequent extra GETs and briefly replace the editor with the loading screen after each save. Consider avoiding the full reload (update savedConfig from draftSnapshot / server response) or adding a background-refresh mode that doesn’t flip loading/hide the editor.
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Mar 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When saveStatus is saved, making a new edit doesn’t clear it, so the UI can still display “Saved” during the 500ms debounce window even though there are unsaved changes. Consider resetting saveStatus back to idle (or a dedicated “pending changes” state) as soon as hasChanges becomes true, so the indicator doesn’t misrepresent the current persistence state.
Copilot
AI
Mar 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AutoSaveStatus is rendered solely from saveStatus, so it can remain visible (e.g., Save failed or Saved) even after the user discards changes and hasChanges becomes false. Consider resetting saveStatus to idle when discarding/when hasChanges becomes false, and wrap onRetry to also clear any pending debounce timer (similar to the Ctrl+S handler) to avoid double-saves.
| <AutoSaveStatus status={saveStatus} onRetry={saveChanges} /> | |
| <AutoSaveStatus status={hasChanges ? saveStatus : 'idle'} onRetry={saveChanges} /> |
Copilot
AI
Mar 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AutoSaveStatus uses <output> for the status text, but it isn’t marked as a live region, and the error variant nests an interactive <button> inside <output>. For better screen reader behavior, consider using role="status"/aria-live="polite" on a non-output wrapper (e.g., a <div>/<span>) and keep the Retry button as a sibling element.
Uh oh!
There was an error while loading. Please reload this page.