From ac6aab1c379781d2a6d4ceff19bdcabc0fb84410 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 23 May 2026 05:21:17 +0100 Subject: [PATCH 1/6] Autosave settings on change --- web/app/components/Header/index.tsx | 2 +- .../Project/Settings/ProjectSettings.tsx | 536 ++++++++++++------ .../Project/Settings/tabs/AccessSettings.tsx | 14 +- .../Project/Settings/tabs/DangerZone.tsx | 7 - .../pages/Project/Settings/tabs/General.tsx | 5 + .../pages/Project/Settings/tabs/Revenue.tsx | 33 +- .../pages/Project/Settings/tabs/Shields.tsx | 4 + web/app/pages/UserSettings/UserSettings.tsx | 251 ++++++-- web/public/locales/en.json | 26 + 9 files changed, 622 insertions(+), 256 deletions(-) diff --git a/web/app/components/Header/index.tsx b/web/app/components/Header/index.tsx index d18fe70e8..e4c014b9e 100644 --- a/web/app/components/Header/index.tsx +++ b/web/app/components/Header/index.tsx @@ -678,7 +678,7 @@ interface HeaderProps { const Header = ({ refPage, transparent }: HeaderProps) => { const { t } = useTranslation('common') - const { isAuthenticated, user, logout } = useAuth() + const { isAuthenticated, logout } = useAuth() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [feedbackOpen, setFeedbackOpen] = useState(false) const { theme, setTheme } = useTheme() diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index f5d5d6b34..728cb1066 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -26,7 +26,7 @@ import { BellRingingIcon, GlobeIcon, } from '@phosphor-icons/react' -import React, { useState, useEffect, useMemo, useRef } from 'react' +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useLoaderData, @@ -78,6 +78,7 @@ import SettingsSidebar, { SettingsTabConfig } from './SettingsSidebar' const MAX_NAME_LENGTH = 50 const MAX_ORIGINS_LENGTH = 300 const MAX_IPBLACKLIST_LENGTH = 300 +const AUTOSAVE_DEBOUNCE_MS = 700 const DELETE_DATA_MODAL_TABS = [ { @@ -323,6 +324,83 @@ interface Form extends Partial> { const DEFAULT_PROJECT_NAME = 'Untitled Project' +const PROJECT_TEXT_AUTOSAVE_TOASTS = { + name: 'project.settings.autosave.name', + origins: 'project.settings.autosave.origins', + ipBlacklist: 'project.settings.autosave.ipBlacklist', + websiteUrl: 'project.settings.autosave.websiteUrl', + brandKeywords: 'project.settings.autosave.brandKeywords', +} as const + +type ProjectTextAutosaveField = keyof typeof PROJECT_TEXT_AUTOSAVE_TOASTS + +const isProjectTextAutosaveField = ( + field: string, +): field is ProjectTextAutosaveField => + Object.prototype.hasOwnProperty.call(PROJECT_TEXT_AUTOSAVE_TOASTS, field) + +const getFormFromProject = (project: Project): Form => ({ + name: project.name || '', + id: project.id, + public: project.public || false, + isPasswordProtected: project.isPasswordProtected || false, + origins: _isString(project.origins) + ? project.origins + : _join(project.origins, ', '), + ipBlacklist: _isString(project.ipBlacklist) + ? project.ipBlacklist + : _join(project.ipBlacklist, ', '), + countryBlacklist: project.countryBlacklist || [], + active: project.active !== false, + botsProtectionLevel: + (project.botsProtectionLevel as 'off' | 'basic' | 'strict') || 'basic', + gscPropertyUri: project.gscPropertyUri || null, + websiteUrl: project.websiteUrl || null, + brandKeywords: project.brandKeywords?.join(', ') || '', + captchaDifficulty: project.captchaDifficulty || 4, + captchaDifficultyMode: project.captchaDifficultyMode || 'manual', +}) + +const normaliseProjectAutosaveValue = (value: unknown) => + JSON.stringify(value ?? null) + +const buildProjectAutosaveFormData = (updates: Partial
) => { + const formData = new FormData() + formData.set('intent', 'update-project') + + Object.entries(updates).forEach(([field, value]) => { + if (value === undefined) return + + if (field === 'countryBlacklist') { + formData.set(field, JSON.stringify(value || [])) + return + } + + if ( + field === 'active' || + field === 'public' || + field === 'isPasswordProtected' + ) { + formData.set(field, value ? 'true' : 'false') + return + } + + if (field === 'captchaDifficulty') { + formData.set(field, String(value)) + return + } + + if (field === 'password') { + if (value) formData.set(field, String(value)) + return + } + + formData.set(field, value === null ? '' : String(value)) + }) + + return formData +} + const ProjectSettings = () => { const { user } = useAuth() @@ -334,30 +412,14 @@ const ProjectSettings = () => { requestOrigin: string | null }>() const fetcher = useFetcher() + const autosaveFetcher = useFetcher() const gscFetcher = useFetcher() const { fetchFilters } = useFiltersProxy() const [project, setProject] = useState(initialProject) - const [form, setForm] = useState(() => ({ - name: initialProject.name || '', - id: initialProject.id, - public: initialProject.public || false, - isPasswordProtected: initialProject.isPasswordProtected || false, - origins: _isString(initialProject.origins) - ? initialProject.origins - : _join(initialProject.origins, ', '), - ipBlacklist: _isString(initialProject.ipBlacklist) - ? initialProject.ipBlacklist - : _join(initialProject.ipBlacklist, ', '), - countryBlacklist: initialProject.countryBlacklist || [], - active: initialProject.active !== false, - botsProtectionLevel: - (initialProject.botsProtectionLevel as 'off' | 'basic' | 'strict') || - 'basic', - gscPropertyUri: initialProject.gscPropertyUri || null, - websiteUrl: initialProject.websiteUrl || null, - brandKeywords: initialProject.brandKeywords?.join(', ') || '', - })) + const [form, setForm] = useState(() => + getFormFromProject(initialProject), + ) const [validated, setValidated] = useState(false) const [errors, setErrors] = useState<{ name?: string @@ -373,12 +435,23 @@ const ProjectSettings = () => { const [showReset, setShowReset] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [isResetting, setIsResetting] = useState(false) - const [isSaving, setIsSaving] = useState(false) const [showTransfer, setShowTransfer] = useState(false) const [transferEmail, setTransferEmail] = useState('') const [dateRange, setDateRange] = useState([]) const [tab, setTab] = useState(DELETE_DATA_MODAL_TABS[0].name) const [showProtected, setShowProtected] = useState(false) + const lastSavedForm = useRef(getFormFromProject(initialProject)) + const lastHandledAutosaveData = useRef(null) + const activeAutosave = useRef<{ + updates: Partial + toastKey: string + } | null>(null) + const pendingAutosave = useRef<{ + updates: Partial + toastKey: string + } | null>(null) + const autosaveTimeout = useRef | null>(null) + const hadCaptchaSecretBeforeRegeneration = useRef(false) const [searchParams, setSearchParams] = useSearchParams() @@ -753,7 +826,6 @@ const ProjectSettings = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Handle fetcher responses useEffect(() => { if (fetcher.data?.success) { const { intent, project: updatedProject } = fetcher.data @@ -762,7 +834,6 @@ const ProjectSettings = () => { if (intent === 'update-project') { if (updatedProject) { - // Merge with existing project to preserve fields like 'role' that aren't returned from update setProject((prev) => prev ? { ...prev, ...updatedProject } : updatedProject, ) @@ -774,7 +845,7 @@ const ProjectSettings = () => { } } setBeenSubmitted(false) - toast.success(t('project.settings.updated')) + toast.success(t('project.settings.autosave.updated')) } else if (intent === 'delete-project') { toast.success(t('project.settings.deleted')) navigate(routes.dashboard) @@ -794,55 +865,30 @@ const ProjectSettings = () => { updatedProject?.captchaSecretKey ) { setCaptchaSecretKey(updatedProject.captchaSecretKey) - toast.success(t('project.settings.updated')) + toast.success( + t( + hadCaptchaSecretBeforeRegeneration.current + ? 'project.settings.captcha.keyRegenerated' + : 'project.settings.captcha.keyGenerated', + ), + ) } else if (intent === 'assign-organisation') { - toast.success(t('apiNotifications.projectAssigned')) + toast.success(t('project.settings.autosave.organisation')) } - setIsSaving(false) setIsDeleting(false) setIsResetting(false) } else if (fetcher.data?.fieldErrors) { setErrors(fetcher.data.fieldErrors) - setIsSaving(false) setIsDeleting(false) setIsResetting(false) } else if (fetcher.data?.error) { toast.error(fetcher.data.error) - setIsSaving(false) setIsDeleting(false) setIsResetting(false) } }, [fetcher.data, t, navigate]) - const onSubmit = (data: Form) => { - if (fetcher.state === 'submitting') return - - setIsSaving(true) - const formData = new FormData() - formData.set('intent', 'update-project') - if (data.name) formData.set('name', data.name) - formData.set('active', data.active ? 'true' : 'false') - formData.set('public', data.public ? 'true' : 'false') - formData.set( - 'isPasswordProtected', - data.isPasswordProtected ? 'true' : 'false', - ) - if (data.password) formData.set('password', data.password) - if (data.origins !== null) formData.set('origins', data.origins) - if (data.ipBlacklist !== null) formData.set('ipBlacklist', data.ipBlacklist) - if (data.botsProtectionLevel) - formData.set('botsProtectionLevel', data.botsProtectionLevel) - if (data.countryBlacklist) - formData.set('countryBlacklist', JSON.stringify(data.countryBlacklist)) - if (data.websiteUrl !== undefined) - formData.set('websiteUrl', data.websiteUrl || '') - if (data.brandKeywords !== undefined) - formData.set('brandKeywords', data.brandKeywords || '') - - fetcher.submit(formData, { method: 'post' }) - } - const onDelete = () => { setShowDelete(false) @@ -897,12 +943,12 @@ const ProjectSettings = () => { const onRegenerateCaptchaKey = () => { if (fetcher.state === 'submitting') return + hadCaptchaSecretBeforeRegeneration.current = Boolean(captchaSecretKey) const formData = new FormData() formData.set('intent', 'regenerate-captcha-key') fetcher.submit(formData, { method: 'post' }) } - // Wrapper for reset that handles tab logic const handleReset = () => { if (fetcher.state === 'submitting') return @@ -926,49 +972,132 @@ const ProjectSettings = () => { } } - const validate = () => { - const allErrors: { - name?: string - origins?: string - ipBlacklist?: string - password?: string - websiteUrl?: string - } = {} - - if (_isEmpty(form.name)) { - allErrors.name = t('project.settings.noNameError') - } + const getValidationErrors = useCallback( + (data: Form) => { + const allErrors: { + name?: string + origins?: string + ipBlacklist?: string + password?: string + websiteUrl?: string + } = {} + + if (_isEmpty(data.name)) { + allErrors.name = t('project.settings.noNameError') + } - if (_size(form.name) > MAX_NAME_LENGTH) { - allErrors.name = t('project.settings.pxCharsError', { - amount: MAX_NAME_LENGTH, - }) - } + if (_size(data.name) > MAX_NAME_LENGTH) { + allErrors.name = t('project.settings.pxCharsError', { + amount: MAX_NAME_LENGTH, + }) + } - if (_size(form.origins) > MAX_ORIGINS_LENGTH) { - allErrors.origins = t('project.settings.oxCharsError', { - amount: MAX_ORIGINS_LENGTH, - }) - } + if (_size(data.origins) > MAX_ORIGINS_LENGTH) { + allErrors.origins = t('project.settings.oxCharsError', { + amount: MAX_ORIGINS_LENGTH, + }) + } - if (_size(form.ipBlacklist) > MAX_IPBLACKLIST_LENGTH) { - allErrors.ipBlacklist = t('project.settings.oxCharsError', { - amount: MAX_IPBLACKLIST_LENGTH, - }) - } + if (_size(data.ipBlacklist) > MAX_IPBLACKLIST_LENGTH) { + allErrors.ipBlacklist = t('project.settings.oxCharsError', { + amount: MAX_IPBLACKLIST_LENGTH, + }) + } - // Validate websiteUrl if provided - if (form.websiteUrl && form.websiteUrl.trim()) { - try { - const url = new URL(form.websiteUrl.trim()) - if (!['http:', 'https:'].includes(url.protocol)) { + if (data.websiteUrl && data.websiteUrl.trim()) { + try { + const url = new URL(data.websiteUrl.trim()) + if (!['http:', 'https:'].includes(url.protocol)) { + allErrors.websiteUrl = t('project.settings.invalidUrl') + } + } catch { allErrors.websiteUrl = t('project.settings.invalidUrl') } - } catch { - allErrors.websiteUrl = t('project.settings.invalidUrl') } + + return allErrors + }, + [t], + ) + + const hasProjectAutosaveChange = useCallback((updates: Partial) => { + return Object.entries(updates).some(([field, value]) => { + if (field === 'password') return Boolean(value) + + return ( + normaliseProjectAutosaveValue( + lastSavedForm.current[field as keyof Form], + ) !== normaliseProjectAutosaveValue(value) + ) + }) + }, []) + + const flushProjectAutosave = useCallback(() => { + if (autosaveTimeout.current) { + clearTimeout(autosaveTimeout.current) + autosaveTimeout.current = null } + const autosave = pendingAutosave.current + if (autosaveFetcher.state !== 'idle' || !autosave) return + + activeAutosave.current = autosave + pendingAutosave.current = null + + autosaveFetcher.submit(buildProjectAutosaveFormData(autosave.updates), { + method: 'post', + }) + }, [autosaveFetcher]) + + const queueProjectAutosave = useCallback( + ( + updates: Partial, + toastKey = 'project.settings.autosave.updated', + immediate = false, + ) => { + const nextForm = { ...form, ...updates } + const allErrors = getValidationErrors(nextForm) + const hasUpdatedFieldError = Object.keys(updates).some( + (field) => allErrors[field as keyof typeof allErrors], + ) + + setErrors(allErrors) + + if (hasUpdatedFieldError) { + setBeenSubmitted(true) + return + } + + if (!hasProjectAutosaveChange(updates)) return + + pendingAutosave.current = { + updates: { + ...pendingAutosave.current?.updates, + ...updates, + }, + toastKey, + } + + if (autosaveTimeout.current) { + clearTimeout(autosaveTimeout.current) + autosaveTimeout.current = null + } + + if (immediate) { + flushProjectAutosave() + return + } + + autosaveTimeout.current = setTimeout( + flushProjectAutosave, + AUTOSAVE_DEBOUNCE_MS, + ) + }, + [flushProjectAutosave, form, getValidationErrors, hasProjectAutosaveChange], + ) + + const validate = () => { + const allErrors = getValidationErrors(form) const valid = _isEmpty(_keys(allErrors)) setErrors(allErrors) @@ -977,7 +1106,70 @@ const ProjectSettings = () => { useEffect(() => { validate() - }, [form]) // eslint-disable-line + }, [form, getValidationErrors]) // eslint-disable-line + + useEffect(() => { + return () => { + if (autosaveTimeout.current) { + clearTimeout(autosaveTimeout.current) + } + } + }, []) + + useEffect(() => { + if ( + autosaveFetcher.state === 'idle' && + pendingAutosave.current && + lastHandledAutosaveData.current === autosaveFetcher.data + ) { + flushProjectAutosave() + } + }, [autosaveFetcher.data, autosaveFetcher.state, flushProjectAutosave]) + + useEffect(() => { + if (autosaveFetcher.state !== 'idle' || !autosaveFetcher.data) return + if (lastHandledAutosaveData.current === autosaveFetcher.data) return + lastHandledAutosaveData.current = autosaveFetcher.data + + if (autosaveFetcher.data.success) { + const { project: updatedProject } = autosaveFetcher.data + const autosave = activeAutosave.current + + if (updatedProject) { + setProject((prev) => + prev ? { ...prev, ...updatedProject } : updatedProject, + ) + } + + if (autosave) { + const savedUpdates = { ...autosave.updates } + delete savedUpdates.password + lastSavedForm.current = { + ...lastSavedForm.current, + ...savedUpdates, + } + toast.success(t(autosave.toastKey)) + } + + activeAutosave.current = null + if (pendingAutosave.current) { + flushProjectAutosave() + } + return + } + + activeAutosave.current = null + + if (autosaveFetcher.data.fieldErrors) { + setErrors(autosaveFetcher.data.fieldErrors) + setBeenSubmitted(true) + } else if (autosaveFetcher.data.error) { + toast.error(autosaveFetcher.data.error) + } + if (pendingAutosave.current) { + flushProjectAutosave() + } + }, [autosaveFetcher.data, autosaveFetcher.state, flushProjectAutosave, t]) const handleInput = (event: React.ChangeEvent) => { const { target } = event @@ -987,6 +1179,37 @@ const ProjectSettings = () => { ...oldForm, [target.name]: value, })) + + if (isProjectTextAutosaveField(target.name)) { + queueProjectAutosave( + { [target.name]: value } as Partial, + PROJECT_TEXT_AUTOSAVE_TOASTS[target.name], + ) + } + } + + const handleInputBlur = (event: React.FocusEvent) => { + const { name, value } = event.currentTarget + + if (!isProjectTextAutosaveField(name)) return + + queueProjectAutosave( + { [name]: value } as Partial, + PROJECT_TEXT_AUTOSAVE_TOASTS[name], + true, + ) + } + + const handleFieldAutosave = ( + updates: Partial, + toastKey: string, + immediate = true, + ) => { + setForm((oldForm) => ({ + ...oldForm, + ...updates, + })) + queueProjectAutosave(updates, toastKey, immediate) } const handleSubmit = (e: React.FormEvent) => { @@ -995,23 +1218,24 @@ const ProjectSettings = () => { setBeenSubmitted(true) if (validated) { - onSubmit(form) + flushProjectAutosave() } } - const onProtected = async () => { + const onProtected = () => { setBeenSubmitted(true) if (validated) { - await onSubmit({ - ...form, - isPasswordProtected: true, - }) - - setForm((prev) => ({ - ...prev, + setForm((oldForm) => ({ + ...oldForm, isPasswordProtected: true, + password: undefined, })) + queueProjectAutosave( + { isPasswordProtected: true, password: form.password }, + 'project.settings.autosave.passwordProtection', + true, + ) setShowProtected(false) } @@ -1088,6 +1312,7 @@ const ProjectSettings = () => { errors={errors} beenSubmitted={beenSubmitted} handleInput={handleInput} + handleBlur={handleInputBlur} /> ) : null} @@ -1097,20 +1322,20 @@ const ProjectSettings = () => { errors={errors} beenSubmitted={beenSubmitted} handleInput={handleInput} + handleBlur={handleInputBlur} botsProtectionLevels={botsProtectionLevels} setBotsLevel={(name) => - setForm((prevForm) => ({ - ...prevForm, - // cast to maintain allowed literal types - botsProtectionLevel: name as any, - })) + handleFieldAutosave( + { botsProtectionLevel: name as any }, + 'project.settings.autosave.botsProtectionLevel', + ) } countryBlacklist={form.countryBlacklist || []} setCountryBlacklist={(countries) => - setForm((prevForm) => ({ - ...prevForm, - countryBlacklist: countries, - })) + handleFieldAutosave( + { countryBlacklist: countries }, + 'project.settings.autosave.countryBlacklist', + ) } /> ) : null} @@ -1123,15 +1348,21 @@ const ProjectSettings = () => { onAssignOrganisation={assignOrganisation} openPasswordModal={() => setShowProtected(true)} handleInput={handleInput} + onPublicChange={(checked) => + handleFieldAutosave( + { public: checked }, + 'project.settings.autosave.public', + ) + } + onPasswordProtectionChange={(checked) => + handleFieldAutosave( + { isPasswordProtected: checked }, + 'project.settings.autosave.passwordProtection', + ) + } sharableLink={sharableLink} /> ) : null} - -
- -
) : null} @@ -1324,6 +1555,13 @@ const ProjectSettings = () => { ...prevForm, gscPropertyUri: item.key, })) + gscFetcher.submit( + { + intent: 'gsc-set-property', + propertyUri: item.key, + }, + { method: 'post' }, + ) }} title={ form.gscPropertyUri || @@ -1338,29 +1576,6 @@ const ProjectSettings = () => { : undefined } /> - - )} @@ -1496,36 +1711,25 @@ const ProjectSettings = () => { }) => { if (item.value === 'auto') { setCaptchaDifficultyMode('auto') + handleFieldAutosave( + { captchaDifficultyMode: 'auto' }, + 'project.settings.autosave.captchaDifficulty', + ) return } setCaptchaDifficultyMode('manual') setCaptchaDifficulty(item.value) + handleFieldAutosave( + { + captchaDifficulty: item.value, + captchaDifficultyMode: 'manual', + }, + 'project.settings.autosave.captchaDifficulty', + ) }} title={selectedCaptchaDifficultyItem.label} /> - ) : ( @@ -1564,9 +1768,11 @@ const ProjectSettings = () => { - setForm((prev) => ({ ...prev, active })) + handleFieldAutosave( + { active }, + 'project.settings.autosave.status', + ) } - isSaving={isSaving} setShowTransfer={setShowTransfer} setShowReset={setShowReset} setShowDelete={setShowDelete} diff --git a/web/app/pages/Project/Settings/tabs/AccessSettings.tsx b/web/app/pages/Project/Settings/tabs/AccessSettings.tsx index e8956c73d..a6f902391 100644 --- a/web/app/pages/Project/Settings/tabs/AccessSettings.tsx +++ b/web/app/pages/Project/Settings/tabs/AccessSettings.tsx @@ -18,6 +18,8 @@ interface AccessSettingsProps { onAssignOrganisation: (id?: string) => Promise openPasswordModal: () => void handleInput: (e: React.ChangeEvent) => void + onPublicChange: (checked: boolean) => void + onPasswordProtectionChange: (checked: boolean) => void sharableLink: string } @@ -28,6 +30,8 @@ const AccessSettings = ({ onAssignOrganisation, openPasswordModal, handleInput, + onPublicChange, + onPasswordProtectionChange, sharableLink, }: AccessSettingsProps) => { const { t } = useTranslation('common') @@ -51,10 +55,7 @@ const AccessSettings = ({ checked={Boolean(form.public)} onChange={(checked) => { if (!form.isPasswordProtected || !checked) { - setForm((prev: any) => ({ - ...prev, - public: checked, - })) + onPublicChange(checked) } }} name='public' @@ -68,10 +69,7 @@ const AccessSettings = ({ checked={Boolean(form.isPasswordProtected)} onChange={(checked) => { if (!checked) { - setForm((prev: any) => ({ - ...prev, - isPasswordProtected: false, - })) + onPasswordProtectionChange(false) return } diff --git a/web/app/pages/Project/Settings/tabs/DangerZone.tsx b/web/app/pages/Project/Settings/tabs/DangerZone.tsx index c47437aae..aa769bcf4 100644 --- a/web/app/pages/Project/Settings/tabs/DangerZone.tsx +++ b/web/app/pages/Project/Settings/tabs/DangerZone.tsx @@ -28,7 +28,6 @@ const DangerAction = ({ title, description, action }: DangerActionProps) => ( interface DangerZoneProps { isActive: boolean onToggleActive: (active: boolean) => void - isSaving: boolean setShowTransfer: (show: boolean) => void setShowReset: (show: boolean) => void setShowDelete: (show: boolean) => void @@ -39,7 +38,6 @@ interface DangerZoneProps { const DangerZone = ({ isActive, onToggleActive, - isSaving, setShowTransfer, setShowReset, setShowDelete, @@ -64,11 +62,6 @@ const DangerZone = ({ classes={{ hint: 'max-w-prose' }} /> -
- -
diff --git a/web/app/pages/Project/Settings/tabs/General.tsx b/web/app/pages/Project/Settings/tabs/General.tsx index b73098e10..5a4c227af 100644 --- a/web/app/pages/Project/Settings/tabs/General.tsx +++ b/web/app/pages/Project/Settings/tabs/General.tsx @@ -17,6 +17,7 @@ interface GeneralProps { } beenSubmitted: boolean handleInput: (e: React.ChangeEvent) => void + handleBlur: (e: React.FocusEvent) => void } const General = ({ @@ -24,6 +25,7 @@ const General = ({ errors, beenSubmitted, handleInput, + handleBlur, }: GeneralProps) => { const { t } = useTranslation('common') @@ -40,6 +42,7 @@ const General = ({ placeholder='My awesome project' className='mt-2' onChange={handleInput} + onBlur={handleBlur} error={beenSubmitted ? errors.name : null} /> diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index 3062f86fd..d97889ffa 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -1,5 +1,5 @@ import { ArrowUpRightIcon, ArrowSquareOutIcon } from '@phosphor-icons/react' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' @@ -88,6 +88,7 @@ const Revenue = ({ projectId }: Props) => { }) const [selectedCurrency, setSelectedCurrency] = useState('USD') + const pendingCurrency = useRef(null) const isConnecting = fetcher.state !== 'idle' && @@ -96,10 +97,6 @@ const Revenue = ({ projectId }: Props) => { const isDisconnecting = fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'disconnect-revenue' - const isSavingCurrency = - fetcher.state !== 'idle' && - fetcher.formData?.get('intent') === 'update-revenue-currency' - useEffect(() => { fetcher.submit( { intent: 'get-revenue-status' }, @@ -147,10 +144,11 @@ const Revenue = ({ projectId }: Props) => { } else if (intent === 'update-revenue-currency') { if (success) { toast.success(t('project.settings.revenue.currencyUpdated')) - setStatus((prev) => - prev ? { ...prev, currency: selectedCurrency } : prev, - ) + const currency = pendingCurrency.current || selectedCurrency + setStatus((prev) => (prev ? { ...prev, currency } : prev)) + pendingCurrency.current = null } else if (error) { + pendingCurrency.current = null toast.error(error) } } @@ -187,11 +185,12 @@ const Revenue = ({ projectId }: Props) => { ) } - const handleCurrencyChange = () => { + const handleCurrencyChange = (currency: RevenueCurrency) => { if (!status?.connected) return + pendingCurrency.current = currency fetcher.submit( - { intent: 'update-revenue-currency', currency: selectedCurrency }, + { intent: 'update-revenue-currency', currency }, { method: 'POST', action: `/projects/settings/${projectId}` }, ) } @@ -349,17 +348,11 @@ const Revenue = ({ projectId }: Props) => { `${item.code} – ${item.name} (${item.symbol})` } selectedItem={selectedCurrencyItem} - onSelect={(item) => setSelectedCurrency(item.code)} + onSelect={(item) => { + setSelectedCurrency(item.code) + handleCurrencyChange(item.code) + }} /> - ) => void + handleBlur: (e: React.FocusEvent) => void botsProtectionLevels: readonly BotsProtectionLevelOption[] setBotsLevel: (name: string) => void countryBlacklist: string[] @@ -41,6 +42,7 @@ const Shields = ({ errors, beenSubmitted, handleInput, + handleBlur, botsProtectionLevels, setBotsLevel, countryBlacklist, @@ -119,6 +121,7 @@ const Shields = ({ value={form.origins || ''} className='mt-2' onChange={handleInput} + onBlur={handleBlur} error={beenSubmitted ? errors.origins : null} />
diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index d00a013bb..5199b5d7c 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -20,7 +20,14 @@ import { CreditCardIcon, } from '@phosphor-icons/react' import _round from 'lodash/round' -import React, { useState, useEffect, memo, useMemo, useRef } from 'react' +import React, { + useState, + useEffect, + memo, + useMemo, + useRef, + useCallback, +} from 'react' import { useTranslation, Trans } from 'react-i18next' import { useNavigate, @@ -259,6 +266,7 @@ const UserSettings = () => { i18n: { language }, } = useTranslation('common') const fetcher = useFetcher() + const profileAutosaveFetcher = useFetcher() const metainfoFetcher = useFetcher() const [searchParams, setSearchParams] = useSearchParams() @@ -417,6 +425,18 @@ const UserSettings = () => { const [deletionPassword, setDeletionPassword] = useState('') const lastHandledData = useRef(null) + const lastHandledProfileAutosaveData = useRef( + null, + ) + const profileUpdateToast = useRef('profileSettings.updated') + const activeProfileAutosave = useRef<{ + additionalData: Record + toastKey: string + } | null>(null) + const pendingProfileAutosave = useRef<{ + additionalData: Record + toastKey: string + } | null>(null) const passwordChangedRef = useRef(false) const pendingPasswordChangeRef = useRef(false) const pendingToggles = useRef>(new Map()) @@ -444,26 +464,26 @@ const UserSettings = () => { } mergeUser(updatedUser) - toast.success(t('profileSettings.updated')) + toast.success(t(profileUpdateToast.current)) + profileUpdateToast.current = 'profileSettings.updated' - // If password was changed, log out the user if (passwordChangedRef.current) { passwordChangedRef.current = false logout() } } else if (intent === 'generate-api-key' && apiKey) { mergeUser({ apiKey }) - toast.success(t('profileSettings.updated')) + toast.success(t('profileSettings.autosave.apiKeyGenerated')) } else if (intent === 'delete-api-key') { mergeUser({ apiKey: null }) - toast.success(t('profileSettings.updated')) + toast.success(t('profileSettings.autosave.apiKeyDeleted')) } else if (intent === 'toggle-live-visitors' && updatedUser) { pendingToggles.current.delete('live-visitors') mergeUser(updatedUser) - toast.success(t('profileSettings.updated')) + toast.success(t('profileSettings.autosave.liveVisitors')) } else if (intent === 'toggle-login-notifications') { pendingToggles.current.delete('login-notifications') - toast.success(t('profileSettings.updated')) + toast.success(t('profileSettings.autosave.loginNotifications')) } else if (intent === 'confirm-email') { setCookie(CONFIRMATION_TIMEOUT, true, 600) toast.success(t('profileSettings.confSent')) @@ -510,6 +530,99 @@ const UserSettings = () => { } }, [fetcher.data, fetcher.state, mergeUser, t, logout, navigate, loadUser]) + const flushProfileAutosave = useCallback(() => { + const autosave = pendingProfileAutosave.current + if (profileAutosaveFetcher.state !== 'idle' || !autosave) return + + activeProfileAutosave.current = autosave + pendingProfileAutosave.current = null + + const formData = new FormData() + formData.set('intent', 'update-profile') + formData.set('email', user?.email || '') + + if (autosave.additionalData.timezone) { + formData.set('timezone', autosave.additionalData.timezone as string) + } + + if (autosave.additionalData.timeFormat) { + formData.set('timeFormat', autosave.additionalData.timeFormat as string) + } + + if (autosave.additionalData.reportFrequency) { + formData.set( + 'reportFrequency', + autosave.additionalData.reportFrequency as string, + ) + } + + profileAutosaveFetcher.submit(formData, { method: 'post' }) + }, [profileAutosaveFetcher, user?.email]) + + useEffect(() => { + if ( + profileAutosaveFetcher.state === 'idle' && + pendingProfileAutosave.current && + lastHandledProfileAutosaveData.current === profileAutosaveFetcher.data + ) { + flushProfileAutosave() + } + }, [ + flushProfileAutosave, + profileAutosaveFetcher.data, + profileAutosaveFetcher.state, + ]) + + useEffect(() => { + if (profileAutosaveFetcher.state !== 'idle' || !profileAutosaveFetcher.data) + return + if (lastHandledProfileAutosaveData.current === profileAutosaveFetcher.data) + return + lastHandledProfileAutosaveData.current = profileAutosaveFetcher.data + + if (profileAutosaveFetcher.data.success) { + const { intent, user: updatedUser } = profileAutosaveFetcher.data + + if (intent === 'update-profile' && updatedUser) { + mergeUser(updatedUser) + toast.success( + t( + activeProfileAutosave.current?.toastKey || + 'profileSettings.updated', + ), + ) + activeProfileAutosave.current = null + } + + if (pendingProfileAutosave.current) { + flushProfileAutosave() + } + return + } + + activeProfileAutosave.current = null + + if (profileAutosaveFetcher.data.error) { + const translated = t( + `apiNotifications.${profileAutosaveFetcher.data.error}`, + ) + toast.error( + translated !== `apiNotifications.${profileAutosaveFetcher.data.error}` + ? translated + : profileAutosaveFetcher.data.error, + ) + } + if (pendingProfileAutosave.current) { + flushProfileAutosave() + } + }, [ + flushProfileAutosave, + profileAutosaveFetcher.data, + profileAutosaveFetcher.state, + mergeUser, + t, + ]) + const errors = useMemo(() => { const allErrors: Record = {} @@ -563,9 +676,11 @@ const UserSettings = () => { additionalData?: Record, skipValidation = false, includePassword = false, + toastKey = 'profileSettings.updated', ) => { if (!skipValidation && !validated) return + profileUpdateToast.current = toastKey const formData = new FormData() formData.set('intent', 'update-profile') formData.set('email', form.email || user?.email || '') @@ -588,6 +703,21 @@ const UserSettings = () => { fetcher.submit(formData, { method: 'post' }) } + const submitProfileAutosave = ( + additionalData: Record, + toastKey: string, + ) => { + pendingProfileAutosave.current = { + additionalData: { + ...pendingProfileAutosave.current?.additionalData, + ...additionalData, + }, + toastKey, + } + + flushProfileAutosave() + } + const handleSubmit = ( e: React.FormEvent | null, force?: boolean, @@ -607,7 +737,14 @@ const UserSettings = () => { return } - submitProfileUpdate(undefined, false, hasPasswordUpdateInput) + submitProfileUpdate( + undefined, + false, + hasPasswordUpdateInput, + hasPasswordUpdateInput + ? 'profileSettings.autosave.password' + : 'profileSettings.updated', + ) } } @@ -616,7 +753,12 @@ const UserSettings = () => { if (!form.email || errors.email) return - submitProfileUpdate(undefined, true) + submitProfileUpdate( + undefined, + true, + false, + 'profileSettings.autosave.email', + ) } const handlePasswordSubmit = () => { @@ -633,8 +775,12 @@ const UserSettings = () => { setIsPasswordChangeModalOpened(true) } - const handleTimezoneSave = () => { - submitProfileUpdate({ timezone }) + const handleTimezoneChange = (value: string) => { + setTimezone(value) + submitProfileAutosave( + { timezone: value }, + 'profileSettings.autosave.timezone', + ) } const handleShowLiveVisitorsSave = (checked: boolean) => { @@ -667,8 +813,12 @@ const UserSettings = () => { mergeUser({ receiveLoginNotifications: checked }) } - const handleReportSave = () => { - submitProfileUpdate({ reportFrequency }) + const handleReportFrequencyChange = (frequency: string) => { + setReportFrequency(frequency) + submitProfileAutosave( + { reportFrequency: frequency }, + 'profileSettings.autosave.reportFrequency', + ) } const onAccountDelete = () => { @@ -709,8 +859,14 @@ const UserSettings = () => { fetcher.submit(formData, { method: 'post' }) } - const setAsyncTimeFormat = () => { - submitProfileUpdate({ timeFormat: form.timeFormat }) + const handleTimeFormatChange = ( + timeFormat: NonNullable, + ) => { + setForm((prev) => ({ + ...prev, + timeFormat, + })) + submitProfileAutosave({ timeFormat }, 'profileSettings.autosave.timeFormat') } const toggleShowEmailFields = () => { @@ -1184,14 +1340,10 @@ const UserSettings = () => { description={t('profileSettings.timezoneDesc')} >
- - +
@@ -1205,18 +1357,15 @@ const UserSettings = () => { title={t(`profileSettings.${form.timeFormat}`)} className='w-full' items={translatedTimeFormat} - onSelect={(f) => - setForm((prev) => ({ - ...prev, - timeFormat: - timeFormatArray[ - _findIndex( - translatedTimeFormat, - (freq) => freq === f, - ) - ], - })) - } + onSelect={(f) => { + const nextTimeFormat = timeFormatArray[ + _findIndex(translatedTimeFormat, (freq) => freq === f) + ] as NonNullable | undefined + + if (!nextTimeFormat) return + + handleTimeFormatChange(nextTimeFormat) + }} capitalise selectedItem={ translatedTimeFormat[ @@ -1227,13 +1376,6 @@ const UserSettings = () => { ] } /> -
@@ -1606,16 +1748,17 @@ const UserSettings = () => { label={t('profileSettings.email')} className='w-full' items={translatedFrequencies} - onSelect={(f) => - setReportFrequency( + onSelect={(f) => { + const nextFrequency = reportFrequencies[ _findIndex( translatedFrequencies, (freq) => freq === f, ) - ], - ) - } + ] + + handleReportFrequencyChange(nextFrequency) + }} capitalise selectedItem={ translatedFrequencies[ @@ -1626,13 +1769,6 @@ const UserSettings = () => { ] } /> - @@ -1795,7 +1931,12 @@ const UserSettings = () => { setIsPasswordChangeModalOpened(false) pendingPasswordChangeRef.current = hasPasswordUpdateInput passwordChangedRef.current = false - submitProfileUpdate(undefined, true, hasPasswordUpdateInput) + submitProfileUpdate( + undefined, + true, + hasPasswordUpdateInput, + 'profileSettings.autosave.password', + ) }} closeText={t('common.cancel')} submitText={t('common.continue')} diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 274c76fa9..af48e1b36 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -901,6 +901,17 @@ "reportSent": "The GDPR data report has been sent to your email address.", "tryAgainInXDays": "Please, try again later. You can request a GDPR Export only once per {{amount}} days.", "updated": "Your account settings have been updated", + "autosave": { + "email": "Email change confirmation sent", + "password": "Password updated", + "timezone": "Timezone updated", + "timeFormat": "Time format updated", + "reportFrequency": "Email report frequency updated", + "liveVisitors": "Live visitors title setting updated", + "loginNotifications": "Login notifications updated", + "apiKeyGenerated": "API key generated", + "apiKeyDeleted": "API key deleted" + }, "confTimeout": "An email has already been sent, check your mailbox or try again in a few minutes", "confSent": "An account confirmation link has been sent to your email", "noConfLeft": "Unfortunately, you've ran out of your email confirmation requests.\nPlease make sure you are able to receive e-mails and check your SPAM folder again for messages.\nYou may try to use a different email address or contact our customer support service.", @@ -2366,6 +2377,21 @@ "learnMore": "Learn how we block bots" }, "updated": "The project's settings were updated", + "autosave": { + "name": "Project name updated", + "websiteUrl": "Website URL updated", + "brandKeywords": "Brand keywords updated", + "origins": "Allowed origins updated", + "ipBlacklist": "IP blacklist updated", + "countryBlacklist": "Country blacklist updated", + "botsProtectionLevel": "Bot traffic filtering updated", + "public": "Public dashboard setting updated", + "passwordProtection": "Password protection updated", + "status": "Project status updated", + "captchaDifficulty": "CAPTCHA difficulty updated", + "organisation": "Project organisation updated", + "updated": "Project setting updated" + }, "created": "The project has been created", "pxCharsError": "Project name cannot be longer than {{amount}} characters.", "oxCharsError": "A list of allowed origins has to be smaller than {{amount}} symbols.", From d7bc8f0863c15bb26d74428bdaaddadfcdc8daa1 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 23 May 2026 07:01:11 +0100 Subject: [PATCH 2/6] fix: prevent repeated fetcher side effects --- .../pages/Organisations/Settings/People.tsx | 15 ++- .../pages/Organisations/Settings/Projects.tsx | 7 +- .../pages/Organisations/Settings/index.tsx | 7 +- .../pages/Project/Settings/Emails/Emails.tsx | 27 +++++- .../pages/Project/Settings/People/People.tsx | 78 +++++++++------- .../Project/Settings/ProjectSettings.tsx | 5 + .../pages/Project/Settings/tabs/Revenue.tsx | 93 ++++++++++--------- .../UserSettings/components/Organisations.tsx | 7 +- .../UserSettings/components/ProjectList.tsx | 7 +- .../components/Socialisations.tsx | 7 +- .../pages/UserSettings/components/TwoFA.tsx | 7 +- 11 files changed, 166 insertions(+), 94 deletions(-) diff --git a/web/app/pages/Organisations/Settings/People.tsx b/web/app/pages/Organisations/Settings/People.tsx index 97fcf8372..310c1e9c9 100644 --- a/web/app/pages/Organisations/Settings/People.tsx +++ b/web/app/pages/Organisations/Settings/People.tsx @@ -17,7 +17,7 @@ import { CaretDownIcon, CheckIcon, } from '@phosphor-icons/react' -import React, { Fragment, useState, useEffect } from 'react' +import React, { Fragment, useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' @@ -83,7 +83,9 @@ const UsersList = ({ members, onRemove, fetcher }: UsersListProps) => { } = useTranslation('common') const { user } = useAuth() - const changeRole = (memberId: string, newRole: Role) => { + const changeRole = (memberId: string, currentRole: Role, newRole: Role) => { + if (newRole === currentRole || fetcher.state !== 'idle') return + const formData = new FormData() formData.set('intent', 'update-member-role') formData.set('memberId', memberId) @@ -144,7 +146,9 @@ const UsersList = ({ members, onRemove, fetcher }: UsersListProps) => {
- {_isEmpty(share) ? ( + {_isEmpty(members) ? ( setShowModal(true)} /> ) : (
@@ -395,7 +414,7 @@ const People = ({ project }: PeopleProps) => { - {_map(share, (data) => ( + {_map(members, (data) => ( { language={language} authedUserEmail={currentUser?.email} projectId={id} + onRoleUpdated={(shareId, role) => { + setMembers((current) => + _map(current, (member) => + member.id === shareId ? { ...member, role } : member, + ), + ) + }} /> ))} diff --git a/web/app/routes/organisations.$id.tsx b/web/app/routes/organisations.$id.tsx index 3d011b838..d95dc6ead 100644 --- a/web/app/routes/organisations.$id.tsx +++ b/web/app/routes/organisations.$id.tsx @@ -90,6 +90,9 @@ export interface OrganisationSettingsActionData { } organisation?: DetailedOrganisation availableProjects?: AvailableProjectsResponse + memberId?: string + projectId?: string + role?: string } export async function action({ request, params }: ActionFunctionArgs) { @@ -221,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, memberId }, { headers: createHeadersWithCookies(result.cookies) }, ) } @@ -247,7 +250,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, memberId, role }, { headers: createHeadersWithCookies(result.cookies) }, ) } @@ -275,7 +278,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, projectId }, { headers: createHeadersWithCookies(result.cookies) }, ) } @@ -306,7 +309,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, projectId }, { headers: createHeadersWithCookies(result.cookies) }, ) } diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 4a6fbc80f..aa9b3dc64 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -99,6 +99,8 @@ export interface ProjectSettingsActionData { proxyDomains?: ProxyDomain[] proxyDomain?: ProxyDomain proxyDomainKeywordWarning?: boolean + shareId?: string + role?: string } export async function action({ request, params }: ActionFunctionArgs) { @@ -544,7 +546,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, shareId }, { headers: createHeadersWithCookies(result.cookies) }, ) } @@ -573,7 +575,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return data( - { intent, success: true }, + { intent, success: true, shareId, role }, { headers: createHeadersWithCookies(result.cookies) }, ) } From 9996e93a922e482c20ad16ec944e2336c6414f4a Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 23 May 2026 07:53:59 +0100 Subject: [PATCH 4/6] fix settings autosave state rollback --- .../EnableWebPushButton.tsx | 17 ++++-- .../NotificationChannels.tsx | 18 ++++-- .../hooks/useDeduplicateFetcherResponse.ts | 48 ++++++++++++++++ .../pages/Organisations/Settings/People.tsx | 11 ++-- .../pages/Organisations/Settings/Projects.tsx | 18 ++++-- .../pages/Organisations/Settings/index.tsx | 11 ++-- .../Settings/Alerts/ProjectAlertsSettings.tsx | 11 ++-- .../Settings/Alerts/ProjectAlertsView.tsx | 11 ++-- .../pages/Project/Settings/Annotations.tsx | 11 ++-- .../pages/Project/Settings/Emails/Emails.tsx | 33 +++++------ .../pages/Project/Settings/People/People.tsx | 31 ++++++++--- .../Project/Settings/ProjectSettings.tsx | 36 +++++++----- .../pages/Project/Settings/tabs/Revenue.tsx | 55 +++++++++++++------ web/app/pages/UserSettings/UserSettings.tsx | 18 ++++-- .../UserSettings/components/Organisations.tsx | 18 ++++-- .../UserSettings/components/ProjectList.tsx | 19 +++++-- .../components/Socialisations.tsx | 11 ++-- .../pages/UserSettings/components/TwoFA.tsx | 11 ++-- 18 files changed, 268 insertions(+), 120 deletions(-) create mode 100644 web/app/hooks/useDeduplicateFetcherResponse.ts diff --git a/web/app/components/NotificationChannels/EnableWebPushButton.tsx b/web/app/components/NotificationChannels/EnableWebPushButton.tsx index 24ab4dfa1..38c4f5f11 100644 --- a/web/app/components/NotificationChannels/EnableWebPushButton.tsx +++ b/web/app/components/NotificationChannels/EnableWebPushButton.tsx @@ -1,9 +1,10 @@ import { BellRingingIcon } from '@phosphor-icons/react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import type { NotificationChannelActionData } from '~/routes/notification-channel' import Button from '~/ui/Button' @@ -44,7 +45,8 @@ const EnableWebPushButton = ({ }: EnableWebPushButtonProps) => { const { t } = useTranslation('common') const subscribeFetcher = useFetcher() - const lastSubscribeData = useRef(null) + const shouldHandleSubscribeData = + useDeduplicateFetcherResponse() const [busy, setBusy] = useState(false) const [supported, setSupported] = useState(true) @@ -59,8 +61,7 @@ const EnableWebPushButton = ({ useEffect(() => { if (subscribeFetcher.state !== 'idle' || !subscribeFetcher.data) return - if (lastSubscribeData.current === subscribeFetcher.data) return - lastSubscribeData.current = subscribeFetcher.data + if (!shouldHandleSubscribeData(subscribeFetcher.data)) return setBusy(false) if (subscribeFetcher.data.error) { toast.error(subscribeFetcher.data.error) @@ -70,7 +71,13 @@ const EnableWebPushButton = ({ toast.success(t('notificationChannels.webpush.enabled')) onSubscribed?.() } - }, [subscribeFetcher.state, subscribeFetcher.data, t, onSubscribed]) + }, [ + subscribeFetcher.state, + subscribeFetcher.data, + t, + onSubscribed, + shouldHandleSubscribeData, + ]) const onClick = async () => { if (!supported) { diff --git a/web/app/components/NotificationChannels/NotificationChannels.tsx b/web/app/components/NotificationChannels/NotificationChannels.tsx index a5bcae69b..a9838e43e 100644 --- a/web/app/components/NotificationChannels/NotificationChannels.tsx +++ b/web/app/components/NotificationChannels/NotificationChannels.tsx @@ -7,11 +7,12 @@ import { TrashIcon, } from '@phosphor-icons/react' import _map from 'lodash/map' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import type { NotificationChannel, NotificationChannelType, @@ -133,7 +134,8 @@ const NotificationChannels = ({ const listFetcher = useFetcher() const mutateFetcher = useFetcher() - const lastMutateData = useRef(null) + const shouldHandleMutateData = + useDeduplicateFetcherResponse() const [channels, setChannels] = useState([]) const [isLoaded, setIsLoaded] = useState(false) @@ -183,8 +185,7 @@ const NotificationChannels = ({ useEffect(() => { if (mutateFetcher.state !== 'idle' || !mutateFetcher.data) return - if (lastMutateData.current === mutateFetcher.data) return - lastMutateData.current = mutateFetcher.data + if (!shouldHandleMutateData(mutateFetcher.data)) return const { intent, success, error } = mutateFetcher.data if (error) { @@ -212,7 +213,14 @@ const NotificationChannels = ({ toast.success(t('notificationChannels.verifyKickedOff')) triggerList() } - }, [mutateFetcher.state, mutateFetcher.data, t, allowedTypes, triggerList]) + }, [ + mutateFetcher.state, + mutateFetcher.data, + t, + allowedTypes, + triggerList, + shouldHandleMutateData, + ]) const submitMutate = (formData: FormData) => mutateFetcher.submit(formData, { diff --git a/web/app/hooks/useDeduplicateFetcherResponse.ts b/web/app/hooks/useDeduplicateFetcherResponse.ts new file mode 100644 index 000000000..74ad80caf --- /dev/null +++ b/web/app/hooks/useDeduplicateFetcherResponse.ts @@ -0,0 +1,48 @@ +import { useCallback, useRef } from 'react' + +const INITIAL = Symbol('initial') + +type Comparator = (previous: TData, current: TData) => boolean + +interface Options { + getKey?: (data: TData) => TKey + isEqual?: Comparator +} + +const isSameReference = (previous: TData, current: TData) => + Object.is(previous, current) + +export const useDeduplicateFetcherResponse = ({ + getKey, + isEqual = isSameReference, +}: Options = {}) => { + const lastHandledData = useRef(INITIAL) + const lastHandledKey = useRef(INITIAL) + + return useCallback( + (data: TData | null | undefined): data is TData => { + if (data == null) return false + + if (getKey) { + const key = getKey(data) + + if (Object.is(lastHandledKey.current, key)) return false + + lastHandledKey.current = key + lastHandledData.current = data + return true + } + + if ( + lastHandledData.current !== INITIAL && + isEqual(lastHandledData.current, data) + ) { + return false + } + + lastHandledData.current = data + return true + }, + [getKey, isEqual], + ) +} diff --git a/web/app/pages/Organisations/Settings/People.tsx b/web/app/pages/Organisations/Settings/People.tsx index 111af3635..5ebca9b7a 100644 --- a/web/app/pages/Organisations/Settings/People.tsx +++ b/web/app/pages/Organisations/Settings/People.tsx @@ -18,11 +18,12 @@ import { CaretDownIcon, CheckIcon, } from '@phosphor-icons/react' -import React, { Fragment, useState, useEffect, useRef } from 'react' +import React, { Fragment, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { roles, INVITATION_EXPIRES_IN } from '~/lib/constants' import { DetailedOrganisation, Role } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' @@ -238,7 +239,8 @@ const People = ({ organisation }: PeopleProps) => { role?: string }>({}) const [validated, setValidated] = useState(false) - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [showDeleteModal, setShowDeleteModal] = useState(false) const [memberToRemove, setMemberToRemove] = useState< @@ -273,8 +275,7 @@ const People = ({ organisation }: PeopleProps) => { /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.success) { const { intent } = fetcher.data @@ -305,7 +306,7 @@ const People = ({ organisation }: PeopleProps) => { } else if (fetcher.data?.error) { toast.error(fetcher.data.error) } - }, [fetcher.data, memberToRemove?.id, t]) + }, [fetcher.data, memberToRemove?.id, t, shouldHandleFetcherData]) /* eslint-enable react-hooks/set-state-in-effect */ const validate = () => { diff --git a/web/app/pages/Organisations/Settings/Projects.tsx b/web/app/pages/Organisations/Settings/Projects.tsx index 281d4060d..1433559d8 100644 --- a/web/app/pages/Organisations/Settings/Projects.tsx +++ b/web/app/pages/Organisations/Settings/Projects.tsx @@ -8,12 +8,13 @@ import _filter from 'lodash/filter' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _replace from 'lodash/replace' -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '~/ui/Link' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import useDebounce from '~/hooks/useDebounce' import { DetailedOrganisation } from '~/lib/models/Organisation' import { Project } from '~/lib/models/Project' @@ -208,7 +209,8 @@ export const Projects = ({ organisation }: ProjectsProps) => { DetailedOrganisation['projects'][number] | null >(null) const [selectedProject, setSelectedProject] = useState(null) - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [currentPage, setCurrentPage] = useState(1) const [search, setSearch] = useState('') @@ -231,8 +233,7 @@ export const Projects = ({ organisation }: ProjectsProps) => { /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.success) { const { intent } = fetcher.data @@ -270,7 +271,14 @@ export const Projects = ({ organisation }: ProjectsProps) => { } else if (fetcher.data?.error) { toast.error(fetcher.data.error) } - }, [fetcher.data, projectToRemove?.id, selectedProject, t, user?.email]) + }, [ + fetcher.data, + projectToRemove?.id, + selectedProject, + t, + user?.email, + shouldHandleFetcherData, + ]) /* eslint-enable react-hooks/set-state-in-effect */ const filteredProjects = useMemo(() => { diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index 9d3bf755a..22fa5f370 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -11,7 +11,7 @@ import { UserCircleIcon, WarningOctagonIcon, } from '@phosphor-icons/react' -import React, { useState, useEffect, useMemo, useRef } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '~/ui/Link' import { @@ -23,6 +23,7 @@ import { } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' import type { @@ -65,7 +66,8 @@ const OrganisationSettings = () => { const [beenSubmitted, setBeenSubmitted] = useState(false) const [showDelete, setShowDelete] = useState(false) const [isFormDirty, setIsFormDirty] = useState(false) - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const organisation = loaderData?.organisation || null const error = loaderData?.error @@ -90,8 +92,7 @@ const OrganisationSettings = () => { useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.success) { const { intent } = fetcher.data @@ -118,7 +119,7 @@ const OrganisationSettings = () => { toast.error(fetcher.data.error) setShowDelete(false) } - }, [fetcher.data, t, navigate, revalidator]) + }, [fetcher.data, t, navigate, revalidator, shouldHandleFetcherData]) const isOrganisationOwner = useMemo(() => { if (!organisation) { diff --git a/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx b/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx index d3defa4b2..f43e5df9f 100644 --- a/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx +++ b/web/app/pages/Project/Settings/Alerts/ProjectAlertsSettings.tsx @@ -12,12 +12,13 @@ import _size from 'lodash/size' import _split from 'lodash/split' import _toNumber from 'lodash/toNumber' import _values from 'lodash/values' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useTranslation, Trans } from 'react-i18next' import { Link } from '~/ui/Link' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { QUERY_CONDITION, QUERY_METRIC, QUERY_TIME } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import type { NotificationChannel } from '~/lib/models/NotificationChannel' @@ -67,7 +68,8 @@ const ProjectAlertsSettings = ({ const { t } = useTranslation('common') const fetcher = useFetcher() const channelsFetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [form, setForm] = useState>({ pid: projectId, @@ -118,8 +120,7 @@ const ProjectAlertsSettings = ({ // Handle fetcher responses useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return const { intent, success, data, error: fetcherError } = fetcher.data @@ -154,7 +155,7 @@ const ProjectAlertsSettings = ({ toast.error(fetcherError) } } - }, [fetcher.state, fetcher.data, t, onSave]) + }, [fetcher.state, fetcher.data, t, onSave, shouldHandleFetcherData]) useEffect(() => { if (authLoading) { diff --git a/web/app/pages/Project/Settings/Alerts/ProjectAlertsView.tsx b/web/app/pages/Project/Settings/Alerts/ProjectAlertsView.tsx index 60b7d2b19..c52df411e 100644 --- a/web/app/pages/Project/Settings/Alerts/ProjectAlertsView.tsx +++ b/web/app/pages/Project/Settings/Alerts/ProjectAlertsView.tsx @@ -16,12 +16,13 @@ import { FileTextIcon, BellSlashIcon, } from '@phosphor-icons/react' -import { useMemo, useState, useEffect, useRef } from 'react' +import { useMemo, useState, useEffect } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Link } from '~/ui/Link' import { useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { QUERY_METRIC, PLAN_LIMITS, DEFAULT_ALERTS_TAKE } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import PaidFeature from '~/modals/PaidFeature' @@ -255,7 +256,8 @@ const ProjectAlertsInner = ({ const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) const [searchParams, setSearchParams] = useSearchParams() const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() // Track if we're in pagination mode const [isSearchMode, setIsSearchMode] = useState(false) @@ -300,8 +302,7 @@ const ProjectAlertsInner = ({ // Handle fetcher responses useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return const { intent, success, data, error: fetcherError } = fetcher.data @@ -325,7 +326,7 @@ const ProjectAlertsInner = ({ setError(fetcherError) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetcher.state, fetcher.data, t, page]) + }, [fetcher.state, fetcher.data, t, page, shouldHandleFetcherData]) // Handle page changes - use fetcher for pagination useEffect(() => { diff --git a/web/app/pages/Project/Settings/Annotations.tsx b/web/app/pages/Project/Settings/Annotations.tsx index 97e35c79c..8cc377181 100644 --- a/web/app/pages/Project/Settings/Annotations.tsx +++ b/web/app/pages/Project/Settings/Annotations.tsx @@ -2,11 +2,12 @@ import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _size from 'lodash/size' import { PencilIcon, TrashIcon, PlusIcon } from '@phosphor-icons/react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { Annotation } from '~/lib/models/Project' import AnnotationModal from '~/modals/AnnotationModal' import type { ProjectViewActionData } from '~/routes/projects.$id' @@ -26,7 +27,8 @@ interface AnnotationsProps { const Annotations = ({ projectId, allowedToManage }: AnnotationsProps) => { const { t } = useTranslation('common') const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [annotations, setAnnotations] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -53,8 +55,7 @@ const Annotations = ({ projectId, allowedToManage }: AnnotationsProps) => { // Handle fetcher responses useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return const { intent, success, data, error } = fetcher.data @@ -92,7 +93,7 @@ const Annotations = ({ projectId, allowedToManage }: AnnotationsProps) => { toast.error(error) setIsLoading(false) } - }, [fetcher.state, fetcher.data, t, loadAnnotations]) + }, [fetcher.state, fetcher.data, t, loadAnnotations, shouldHandleFetcherData]) useEffect(() => { loadAnnotations() diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index 0d8b10308..08b3f79e6 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher, useParams } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import useOnClickOutside from '~/hooks/useOnClickOutside' import { reportFrequencyForEmailsOptions } from '~/lib/constants' import { Subscriber } from '~/lib/models/Subscriber' @@ -112,7 +113,8 @@ const EmailList = ({ data, onRemove, setEmails }: EmailListProps) => { } = useTranslation('common') const { id: projectId } = useParams() const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [open, setOpen] = useState(false) const openRef = useRef(null) useOnClickOutside(openRef, () => setOpen(false)) @@ -120,8 +122,7 @@ const EmailList = ({ data, onRemove, setEmails }: EmailListProps) => { useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.intent === 'update-subscriber') { if (fetcher.data.success && fetcher.data.subscriber) { @@ -138,7 +139,7 @@ const EmailList = ({ data, onRemove, setEmails }: EmailListProps) => { ) } } - }, [fetcher.data, setEmails, t]) + }, [fetcher.data, setEmails, t, shouldHandleFetcherData]) const changeRole = (reportType: { value: string; label: string }) => { if (reportType.value === reportFrequency || fetcher.state !== 'idle') return @@ -310,9 +311,12 @@ const Emails = ({ projectId }: { projectId: string }) => { const [showDeleteModal, setShowDeleteModal] = useState(false) const [emailToRemove, setEmailToRemove] = useState(null) const removingEmailId = useRef(null) - const lastHandledLoadData = useRef(null) - const lastHandledData = useRef(null) - const lastHandledRemoveData = useRef(null) + const shouldHandleLoadData = + useDeduplicateFetcherResponse() + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() + const shouldHandleRemoveData = + useDeduplicateFetcherResponse() const isDeleting = removeFetcher.state !== 'idle' @@ -329,8 +333,7 @@ const Emails = ({ projectId }: { projectId: string }) => { useEffect(() => { if (!loadFetcher.data) return - if (lastHandledLoadData.current === loadFetcher.data) return - lastHandledLoadData.current = loadFetcher.data + if (!shouldHandleLoadData(loadFetcher.data)) return if (loadFetcher.data?.intent === 'get-subscribers') { if (loadFetcher.data.success) { @@ -344,12 +347,11 @@ const Emails = ({ projectId }: { projectId: string }) => { } setTimeout(() => setLoading(false), 0) } - }, [loadFetcher.data]) + }, [loadFetcher.data, shouldHandleLoadData]) useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.intent === 'add-subscriber') { if (fetcher.data.success && fetcher.data.subscriber) { @@ -366,12 +368,11 @@ const Emails = ({ projectId }: { projectId: string }) => { ) } } - }, [fetcher.data, t]) + }, [fetcher.data, t, shouldHandleFetcherData]) useEffect(() => { if (removeFetcher.state !== 'idle' || !removeFetcher.data) return - if (lastHandledRemoveData.current === removeFetcher.data) return - lastHandledRemoveData.current = removeFetcher.data + if (!shouldHandleRemoveData(removeFetcher.data)) return if (removeFetcher.data?.intent === 'remove-subscriber') { if (removeFetcher.data.success) { @@ -390,7 +391,7 @@ const Emails = ({ projectId }: { projectId: string }) => { ) } } - }, [removeFetcher.data, removeFetcher.state, t]) + }, [removeFetcher.data, removeFetcher.state, t, shouldHandleRemoveData]) const validate = () => { const allErrors: { diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index a469ee1eb..a35f52877 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -16,10 +16,11 @@ import { CaretDownIcon, CheckIcon, } from '@phosphor-icons/react' -import React, { Fragment, useState, useEffect, useRef } from 'react' +import React, { Fragment, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { roles, INVITATION_EXPIRES_IN, isSelfhosted } from '~/lib/constants' import { Role } from '~/lib/models/Organisation' import { Project, ShareOwnerProject } from '~/lib/models/Project' @@ -88,13 +89,13 @@ const TableUserRow = ({ }: TableUserRowProps) => { const { t } = useTranslation('common') const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const { id, created, confirmed, role, user } = data || {} useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data.intent === 'change-share-role') { if (fetcher.data.success) { @@ -106,7 +107,13 @@ const TableUserRow = ({ toast.error(fetcher.data.error) } } - }, [fetcher.state, fetcher.data, onRoleUpdated, t]) + }, [ + fetcher.state, + fetcher.data, + onRoleUpdated, + t, + shouldHandleFetcherData, + ]) const changeRole = (newRole: string) => { if (newRole === role || fetcher.state !== 'idle') return @@ -252,7 +259,8 @@ const People = ({ project }: PeopleProps) => { role?: string }>({}) const [validated, setValidated] = useState(false) - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [members, setMembers] = useState(() => share || []) const [showDeleteModal, setShowDeleteModal] = useState(false) @@ -267,8 +275,7 @@ const People = ({ project }: PeopleProps) => { useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data.intent === 'share-project') { if (fetcher.data.success) { @@ -303,7 +310,13 @@ const People = ({ project }: PeopleProps) => { toast.error(fetcher.data.error) } } - }, [fetcher.state, fetcher.data, memberToRemove?.id, t]) + }, [ + fetcher.state, + fetcher.data, + memberToRemove?.id, + t, + shouldHandleFetcherData, + ]) const validate = () => { const allErrors: { diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index be1e67725..f0f488911 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -37,6 +37,7 @@ import { import { toast } from 'sonner' import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { useRequiredParams } from '~/hooks/useRequiredParams' import { isSelfhosted, FILTERS_PANELS_ORDER, isBrowser } from '~/lib/constants' import { Project } from '~/lib/models/Project' @@ -441,8 +442,11 @@ const ProjectSettings = () => { const [tab, setTab] = useState(DELETE_DATA_MODAL_TABS[0].name) const [showProtected, setShowProtected] = useState(false) const lastSavedForm = useRef(getFormFromProject(initialProject)) - const lastHandledFetcherData = useRef(null) const lastHandledAutosaveData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() + const shouldHandleGscData = + useDeduplicateFetcherResponse() const activeAutosave = useRef<{ updates: Partial toastKey: string @@ -730,16 +734,13 @@ const ProjectSettings = () => { }, [requestOrigin, id]) const [gscPropertiesPending, setGscPropertiesPending] = useState(false) - const lastHandledGscData = useRef(null) + const pendingGscPropertyUri = useRef(null) const gscInitialized = useRef(false) // Handle GSC fetcher responses useEffect(() => { if (gscFetcher.state !== 'idle' || !gscFetcher.data) return - - // Prevent handling the same response twice - if (lastHandledGscData.current === gscFetcher.data) return - lastHandledGscData.current = gscFetcher.data + if (!shouldHandleGscData(gscFetcher.data)) return const { intent, @@ -786,12 +787,21 @@ const ProjectSettings = () => { setGscConnected(false) setGscProperties([]) setGscEmail(null) + pendingGscPropertyUri.current = null setForm((prevForm) => ({ ...prevForm, gscPropertyUri: null, })) toast.success(t('project.settings.gsc.disconnected')) } else if (intent === 'gsc-set-property') { + const propertyUri = pendingGscPropertyUri.current + if (propertyUri) { + setForm((prevForm) => ({ + ...prevForm, + gscPropertyUri: propertyUri, + })) + pendingGscPropertyUri.current = null + } toast.success(t('project.settings.gsc.propertyConnected')) } } else if (gscError) { @@ -806,9 +816,11 @@ const ProjectSettings = () => { setGscConnected(false) setGscEmail(null) setGscProperties([]) + } else if (intent === 'gsc-set-property') { + pendingGscPropertyUri.current = null } } - }, [gscFetcher.state, gscFetcher.data, t]) + }, [gscFetcher.state, gscFetcher.data, t, shouldHandleGscData]) // Fetch GSC properties after status confirms connected useEffect(() => { @@ -829,8 +841,7 @@ const ProjectSettings = () => { useEffect(() => { if (!fetcher.data) return - if (lastHandledFetcherData.current === fetcher.data) return - lastHandledFetcherData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.success) { const { intent, project: updatedProject } = fetcher.data @@ -892,7 +903,7 @@ const ProjectSettings = () => { setIsDeleting(false) setIsResetting(false) } - }, [fetcher.data, t, navigate]) + }, [fetcher.data, t, navigate, shouldHandleFetcherData]) const onDelete = () => { setShowDelete(false) @@ -1556,10 +1567,7 @@ const ProjectSettings = () => { keyExtractor={(item) => item.key} labelExtractor={(item) => item.label} onSelect={(item: { key: string; label: string }) => { - setForm((prevForm) => ({ - ...prevForm, - gscPropertyUri: item.key, - })) + pendingGscPropertyUri.current = item.key gscFetcher.submit( { intent: 'gsc-set-property', diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index 33b0b772c..3de6b403b 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { DOCS_URL } from '~/lib/constants' import type { ProjectSettingsActionData, @@ -33,6 +34,9 @@ const SUPPORTED_CURRENCIES: CurrencyOption[] = [ { code: 'GBP', symbol: '£', name: 'British Pound' }, ] +const isRevenueCurrency = (currency?: string): currency is RevenueCurrency => + currency === 'USD' || currency === 'EUR' || currency === 'GBP' + const STRIPE_REQUIRED_PERMISSIONS = [ 'rak_charge_read', 'rak_subscription_read', @@ -88,8 +92,12 @@ const Revenue = ({ projectId }: Props) => { }) const [selectedCurrency, setSelectedCurrency] = useState('USD') - const pendingCurrency = useRef(null) - const lastHandledData = useRef(null) + const pendingCurrency = useRef<{ + previous: RevenueCurrency + next: RevenueCurrency + } | null>(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const isConnecting = fetcher.state !== 'idle' && @@ -108,8 +116,7 @@ const Revenue = ({ projectId }: Props) => { useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return const { intent, success, error, revenueStatus } = fetcher.data @@ -117,11 +124,7 @@ const Revenue = ({ projectId }: Props) => { setIsLoading(false) if (success && revenueStatus) { setStatus(revenueStatus) - if ( - revenueStatus.currency === 'USD' || - revenueStatus.currency === 'EUR' || - revenueStatus.currency === 'GBP' - ) { + if (isRevenueCurrency(revenueStatus.currency)) { setSelectedCurrency(revenueStatus.currency) } } else if (error) { @@ -148,15 +151,27 @@ const Revenue = ({ projectId }: Props) => { } else if (intent === 'update-revenue-currency') { if (success) { toast.success(t('project.settings.revenue.currencyUpdated')) - const currency = pendingCurrency.current || selectedCurrency - setStatus((prev) => (prev ? { ...prev, currency } : prev)) + const currency = pendingCurrency.current?.next || selectedCurrency + setStatus((prev) => ({ ...(prev || { connected: false }), currency })) pendingCurrency.current = null } else if (error) { + const previousCurrency = pendingCurrency.current?.previous + if (previousCurrency) { + setSelectedCurrency(previousCurrency) + } pendingCurrency.current = null toast.error(error) } } - }, [fetcher.state, fetcher.data, t, projectId, selectedCurrency, fetcher]) + }, [ + fetcher.state, + fetcher.data, + t, + projectId, + selectedCurrency, + fetcher, + shouldHandleFetcherData, + ]) const handleApiKeyChange = (provider: RevenueProvider, value: string) => { setApiKeys((prev) => ({ ...prev, [provider]: value })) @@ -188,10 +203,11 @@ const Revenue = ({ projectId }: Props) => { ) } - const handleCurrencyChange = (currency: RevenueCurrency) => { - if (!status?.connected) return - - pendingCurrency.current = currency + const handleCurrencyChange = ( + currency: RevenueCurrency, + previousCurrency: RevenueCurrency, + ) => { + pendingCurrency.current = { previous: previousCurrency, next: currency } fetcher.submit( { intent: 'update-revenue-currency', currency }, { method: 'POST', action: `/projects/settings/${projectId}` }, @@ -352,8 +368,13 @@ const Revenue = ({ projectId }: Props) => { } selectedItem={selectedCurrencyItem} onSelect={(item) => { + if (item.code === selectedCurrency) return + + const previousCurrency = isRevenueCurrency(status?.currency) + ? status.currency + : selectedCurrency setSelectedCurrency(item.code) - handleCurrencyChange(item.code) + handleCurrencyChange(item.code, previousCurrency) }} />
diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index 5199b5d7c..d2cc6d00f 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -50,6 +50,7 @@ import { paddleLanguageMapping, } from '~/lib/constants' import BillingPricing from '~/components/pricing/BillingPricing' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { usePaddle } from '~/hooks/usePaddle' import { changeLanguage } from '~/i18n' import { DEFAULT_METAINFO } from '~/lib/models/Metainfo' @@ -424,10 +425,11 @@ const UserSettings = () => { const [deletionFeedback, setDeletionFeedback] = useState('') const [deletionPassword, setDeletionPassword] = useState('') - const lastHandledData = useRef(null) const lastHandledProfileAutosaveData = useRef( null, ) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const profileUpdateToast = useRef('profileSettings.updated') const activeProfileAutosave = useRef<{ additionalData: Record @@ -451,8 +453,7 @@ const UserSettings = () => { useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data.success) { const { intent, user: updatedUser, apiKey } = fetcher.data @@ -528,7 +529,16 @@ const UserSettings = () => { : fetcher.data.error, ) } - }, [fetcher.data, fetcher.state, mergeUser, t, logout, navigate, loadUser]) + }, [ + fetcher.data, + fetcher.state, + mergeUser, + t, + logout, + navigate, + loadUser, + shouldHandleFetcherData, + ]) const flushProfileAutosave = useCallback(() => { const autosave = pendingProfileAutosave.current diff --git a/web/app/pages/UserSettings/components/Organisations.tsx b/web/app/pages/UserSettings/components/Organisations.tsx index acf99c391..d4e3ddb61 100644 --- a/web/app/pages/UserSettings/components/Organisations.tsx +++ b/web/app/pages/UserSettings/components/Organisations.tsx @@ -1,11 +1,12 @@ import dayjs from 'dayjs' import _filter from 'lodash/filter' import _map from 'lodash/map' -import { memo, useState, useEffect, useRef } from 'react' +import { memo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { OrganisationMembership } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' import type { UserSettingsActionData } from '~/routes/user-settings' @@ -27,13 +28,13 @@ const Organisations = ({ membership }: OrganisationsProps) => { const { organisation, confirmed, role, created } = membership const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const isLoading = fetcher.state !== 'idle' useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.intent === 'reject-organisation-invitation') { if (fetcher.data.success) { @@ -67,7 +68,14 @@ const Organisations = ({ membership }: OrganisationsProps) => { toast.error(fetcher.data.error) } } - }, [fetcher.data, mergeUser, membership.id, t, user?.organisationMemberships]) + }, [ + fetcher.data, + mergeUser, + membership.id, + t, + user?.organisationMemberships, + shouldHandleFetcherData, + ]) const onQuit = () => { fetcher.submit( diff --git a/web/app/pages/UserSettings/components/ProjectList.tsx b/web/app/pages/UserSettings/components/ProjectList.tsx index 8505a5d72..ddb797f59 100644 --- a/web/app/pages/UserSettings/components/ProjectList.tsx +++ b/web/app/pages/UserSettings/components/ProjectList.tsx @@ -1,11 +1,12 @@ import dayjs from 'dayjs' import _filter from 'lodash/filter' import _map from 'lodash/map' -import { memo, useState, useEffect, useRef } from 'react' +import { memo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { SharedProject } from '~/lib/models/SharedProject' import { useAuth } from '~/providers/AuthProvider' import { UserSettingsActionData } from '~/routes/user-settings' @@ -23,7 +24,8 @@ const ProjectList = ({ item }: ProjectListProps) => { } = useTranslation('common') const { user, mergeUser } = useAuth() const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const [showDeleteModal, setShowDeleteModal] = useState(false) const { created, confirmed, id, role, project } = item @@ -32,8 +34,7 @@ const ProjectList = ({ item }: ProjectListProps) => { useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.intent === 'reject-project-share') { if (fetcher.data.success) { @@ -64,7 +65,15 @@ const ProjectList = ({ item }: ProjectListProps) => { toast.error(t('apiNotifications.acceptInvitationError')) } } - }, [fetcher.data, id, item.id, mergeUser, t, user?.sharedProjects]) + }, [ + fetcher.data, + id, + item.id, + mergeUser, + t, + user?.sharedProjects, + shouldHandleFetcherData, + ]) const onQuit = () => { fetcher.submit( diff --git a/web/app/pages/UserSettings/components/Socialisations.tsx b/web/app/pages/UserSettings/components/Socialisations.tsx index a265a9016..06a373225 100644 --- a/web/app/pages/UserSettings/components/Socialisations.tsx +++ b/web/app/pages/UserSettings/components/Socialisations.tsx @@ -1,10 +1,11 @@ import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import _map from 'lodash/map' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { useAuthProxy } from '~/hooks/useAuthProxy' import { SSO_PROVIDERS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' @@ -66,14 +67,14 @@ const Socialisations = () => { const { t } = useTranslation('common') const [isLoading, setIsLoading] = useState(false) const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const isUnlinking = fetcher.state !== 'idle' const { generateSSOAuthURL, linkBySSOHash } = useAuthProxy() useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.intent === 'unlink-sso') { if (fetcher.data.success) { @@ -83,7 +84,7 @@ const Socialisations = () => { toast.error(fetcher.data.error) } } - }, [fetcher.data, loadUser, t]) + }, [fetcher.data, loadUser, t, shouldHandleFetcherData]) const linkSSO = async (provider: SSOProvider) => { setIsLoading(true) diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index 45346ba1b..9deb80823 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { QRCode } from 'react-qr-code' import { useFetcher } from 'react-router' import { toast } from 'sonner' import { LockIcon } from '@phosphor-icons/react' +import { useDeduplicateFetcherResponse } from '~/hooks/useDeduplicateFetcherResponse' import { useAuth } from '~/providers/AuthProvider' import type { UserSettingsActionData } from '~/routes/user-settings' import Alert from '~/ui/Alert' @@ -15,7 +16,8 @@ import { Text } from '~/ui/Text' const TwoFA = () => { const { user, mergeUser } = useAuth() const fetcher = useFetcher() - const lastHandledData = useRef(null) + const shouldHandleFetcherData = + useDeduplicateFetcherResponse() const { t } = useTranslation('common') const [twoFAConfigurating, setTwoFAConfigurating] = useState(false) @@ -34,8 +36,7 @@ const TwoFA = () => { useEffect(() => { if (!fetcher.data) return - if (lastHandledData.current === fetcher.data) return - lastHandledData.current = fetcher.data + if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data?.success) { const { intent, twoFAData } = fetcher.data @@ -79,7 +80,7 @@ const TwoFA = () => { } } } - }, [fetcher.data, mergeUser, t]) + }, [fetcher.data, mergeUser, t, shouldHandleFetcherData]) const handle2FAInput = (event: React.ChangeEvent) => { const { From 67f391ed427d5ad922a30f3d567c1b328c8ea320 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 23 May 2026 07:55:05 +0100 Subject: [PATCH 5/6] fix formatting --- web/app/pages/Project/Settings/People/People.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index a35f52877..d951888af 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -107,13 +107,7 @@ const TableUserRow = ({ toast.error(fetcher.data.error) } } - }, [ - fetcher.state, - fetcher.data, - onRoleUpdated, - t, - shouldHandleFetcherData, - ]) + }, [fetcher.state, fetcher.data, onRoleUpdated, t, shouldHandleFetcherData]) const changeRole = (newRole: string) => { if (newRole === role || fetcher.state !== 'idle') return From 4fa5139e543ed5b880899095f1379964d89a9f30 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 23 May 2026 08:21:42 +0100 Subject: [PATCH 6/6] fix potential race conditions --- .../pages/Organisations/Settings/People.tsx | 25 +++++++++---- .../pages/Organisations/Settings/Projects.tsx | 25 ++++++++----- .../pages/Project/Settings/tabs/Revenue.tsx | 35 ++++++++++++------- web/app/pages/UserSettings/UserSettings.tsx | 10 +++++- web/app/routes/projects.settings.$id.tsx | 12 +++++-- 5 files changed, 76 insertions(+), 31 deletions(-) diff --git a/web/app/pages/Organisations/Settings/People.tsx b/web/app/pages/Organisations/Settings/People.tsx index 5ebca9b7a..d6844060d 100644 --- a/web/app/pages/Organisations/Settings/People.tsx +++ b/web/app/pages/Organisations/Settings/People.tsx @@ -18,7 +18,7 @@ import { CaretDownIcon, CheckIcon, } from '@phosphor-icons/react' -import React, { Fragment, useState, useEffect } from 'react' +import React, { Fragment, useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' @@ -246,6 +246,7 @@ const People = ({ organisation }: PeopleProps) => { const [memberToRemove, setMemberToRemove] = useState< DetailedOrganisation['members'][number] | null >(null) + const memberIdToRemove = useRef(null) const { name, members: organisationMembers } = organisation const [members, setMembers] = useState( @@ -283,12 +284,16 @@ const People = ({ organisation }: PeopleProps) => { toast.success(t('apiNotifications.userInvited')) closeModal() } else if (intent === 'remove-member') { - const removedMemberId = fetcher.data.memberId || memberToRemove?.id + const removedMemberId = + fetcher.data.memberId || + fetcher.formData?.get('memberId')?.toString() || + memberIdToRemove.current if (removedMemberId) { setMembers((current) => _filter(current, (member) => member.id !== removedMemberId), ) } + memberIdToRemove.current = null toast.success(t('apiNotifications.orgUserRemoved')) closeRemoveUserModal() } else if (intent === 'update-member-role') { @@ -304,9 +309,12 @@ const People = ({ organisation }: PeopleProps) => { toast.success(t('apiNotifications.roleUpdated')) } } else if (fetcher.data?.error) { + if (fetcher.data.intent === 'remove-member') { + memberIdToRemove.current = null + } toast.error(fetcher.data.error) } - }, [fetcher.data, memberToRemove?.id, t, shouldHandleFetcherData]) + }, [fetcher.data, fetcher.formData, t, shouldHandleFetcherData]) /* eslint-enable react-hooks/set-state-in-effect */ const validate = () => { @@ -366,10 +374,11 @@ const People = ({ organisation }: PeopleProps) => { } } - const onRemove = (member: DetailedOrganisation['members'][number]) => { + const onRemove = (memberId: string) => { + memberIdToRemove.current = memberId const formData = new FormData() formData.set('intent', 'remove-member') - formData.set('memberId', member.id) + formData.set('memberId', memberId) fetcher.submit(formData, { method: 'post' }) } @@ -423,6 +432,7 @@ const People = ({ organisation }: PeopleProps) => { members={members} onRemove={(member) => { setMemberToRemove(member) + memberIdToRemove.current = member.id setShowDeleteModal(true) }} fetcher={fetcher} @@ -434,8 +444,9 @@ const People = ({ organisation }: PeopleProps) => {
{ - await onRemove(memberToRemove!) + onSubmit={() => { + if (!memberToRemove) return + onRemove(memberToRemove.id) closeRemoveUserModal() }} submitText={t('common.yes')} diff --git a/web/app/pages/Organisations/Settings/Projects.tsx b/web/app/pages/Organisations/Settings/Projects.tsx index 1433559d8..acf1ccc10 100644 --- a/web/app/pages/Organisations/Settings/Projects.tsx +++ b/web/app/pages/Organisations/Settings/Projects.tsx @@ -8,7 +8,7 @@ import _filter from 'lodash/filter' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _replace from 'lodash/replace' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '~/ui/Link' import { useFetcher } from 'react-router' @@ -35,6 +35,11 @@ interface SelectAProjectProps { const PROJECT_SELECT_PAGE_SIZE = 5 const PROJECT_LIST_PAGE_SIZE = 20 +type SubmittedProject = Pick & { + admin?: { + email?: string + } +} const SelectAProject = ({ onSelect }: SelectAProjectProps) => { const { t } = useTranslation('common') @@ -209,6 +214,7 @@ export const Projects = ({ organisation }: ProjectsProps) => { DetailedOrganisation['projects'][number] | null >(null) const [selectedProject, setSelectedProject] = useState(null) + const submittedProjectRef = useRef(null) const shouldHandleFetcherData = useDeduplicateFetcherResponse() @@ -238,22 +244,24 @@ export const Projects = ({ organisation }: ProjectsProps) => { if (fetcher.data?.success) { const { intent } = fetcher.data if (intent === 'add-project') { - if (selectedProject) { + const submittedProject = submittedProjectRef.current + if (submittedProject) { setProjects((current) => - current.some((project) => project.id === selectedProject.id) + current.some((project) => project.id === submittedProject.id) ? current : [ ...current, { - id: selectedProject.id, - name: selectedProject.name, + id: submittedProject.id, + name: submittedProject.name, admin: { - email: selectedProject.admin?.email || user?.email || '', + email: submittedProject.admin?.email || user?.email || '', }, }, ], ) } + submittedProjectRef.current = null toast.success(t('apiNotifications.projectAddedToOrganisation')) setShowAddProjectModal(false) setSelectedProject(null) @@ -274,7 +282,6 @@ export const Projects = ({ organisation }: ProjectsProps) => { }, [ fetcher.data, projectToRemove?.id, - selectedProject, t, user?.email, shouldHandleFetcherData, @@ -303,9 +310,11 @@ export const Projects = ({ organisation }: ProjectsProps) => { const onAddProject = () => { if (!selectedProject) return + const submittedProject = selectedProject as SubmittedProject + submittedProjectRef.current = submittedProject const formData = new FormData() formData.set('intent', 'add-project') - formData.set('projectId', selectedProject.id) + formData.set('projectId', submittedProject.id) fetcher.submit(formData, { method: 'post' }) } diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index 3de6b403b..448b57ffd 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -93,9 +93,11 @@ const Revenue = ({ projectId }: Props) => { const [selectedCurrency, setSelectedCurrency] = useState('USD') const pendingCurrency = useRef<{ + requestId: string previous: RevenueCurrency next: RevenueCurrency } | null>(null) + const currencyRequestId = useRef(0) const shouldHandleFetcherData = useDeduplicateFetcherResponse() @@ -149,16 +151,23 @@ const Revenue = ({ projectId }: Props) => { toast.error(error) } } else if (intent === 'update-revenue-currency') { + const pending = pendingCurrency.current + if ( + !pending || + fetcher.data.revenueCurrencyRequestId !== pending.requestId + ) { + return + } + if (success) { toast.success(t('project.settings.revenue.currencyUpdated')) - const currency = pendingCurrency.current?.next || selectedCurrency - setStatus((prev) => ({ ...(prev || { connected: false }), currency })) + setStatus((prev) => ({ + ...(prev || { connected: false }), + currency: pending.next, + })) pendingCurrency.current = null } else if (error) { - const previousCurrency = pendingCurrency.current?.previous - if (previousCurrency) { - setSelectedCurrency(previousCurrency) - } + setSelectedCurrency(pending.previous) pendingCurrency.current = null toast.error(error) } @@ -168,7 +177,6 @@ const Revenue = ({ projectId }: Props) => { fetcher.data, t, projectId, - selectedCurrency, fetcher, shouldHandleFetcherData, ]) @@ -207,9 +215,14 @@ const Revenue = ({ projectId }: Props) => { currency: RevenueCurrency, previousCurrency: RevenueCurrency, ) => { - pendingCurrency.current = { previous: previousCurrency, next: currency } + const requestId = String(++currencyRequestId.current) + pendingCurrency.current = { + requestId, + previous: previousCurrency, + next: currency, + } fetcher.submit( - { intent: 'update-revenue-currency', currency }, + { intent: 'update-revenue-currency', currency, requestId }, { method: 'POST', action: `/projects/settings/${projectId}` }, ) } @@ -370,9 +383,7 @@ const Revenue = ({ projectId }: Props) => { onSelect={(item) => { if (item.code === selectedCurrency) return - const previousCurrency = isRevenueCurrency(status?.currency) - ? status.currency - : selectedCurrency + const previousCurrency = selectedCurrency setSelectedCurrency(item.code) handleCurrencyChange(item.code, previousCurrency) }} diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index d2cc6d00f..0681bc099 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -520,7 +520,15 @@ const UserSettings = () => { passwordChangedRef.current = false setIsPasswordChangeModalOpened(false) } - if (!fetcher.data.error) return + if (!fetcher.data.error) { + const fieldError = Object.values(fetcher.data.fieldErrors || {}).find( + Boolean, + ) + if (fieldError) { + toast.error(fieldError) + } + return + } const translated = t(`apiNotifications.${fetcher.data.error}`) toast.error( diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index aa9b3dc64..766570fcc 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -101,6 +101,7 @@ export interface ProjectSettingsActionData { proxyDomainKeywordWarning?: boolean shareId?: string role?: string + revenueCurrencyRequestId?: string } export async function action({ request, params }: ActionFunctionArgs) { @@ -657,10 +658,11 @@ export async function action({ request, params }: ActionFunctionArgs) { case 'update-revenue-currency': { const currency = formData.get('currency')?.toString() + const revenueCurrencyRequestId = formData.get('requestId')?.toString() if (!currency) { return data( - { intent, error: 'Currency is required' }, + { intent, error: 'Currency is required', revenueCurrencyRequestId }, { status: 400 }, ) } @@ -676,13 +678,17 @@ export async function action({ request, params }: ActionFunctionArgs) { if (result.error) { return data( - { intent, error: result.error as string }, + { + intent, + error: result.error as string, + revenueCurrencyRequestId, + }, { status: 400 }, ) } return data( - { intent, success: true }, + { intent, success: true, revenueCurrencyRequestId }, { headers: createHeadersWithCookies(result.cookies) }, ) }