fix(M13): hydration mismatch on backlog list filter chips (#19)
useState initializers read localStorage synchronously, which produced a different render on client (with persisted filterStatus='blocked') than on server (which has no localStorage and rendered 'all'). The chip-buttons that surface active filters caused a structural DOM mismatch next to the Popover trigger, raising a hydration error. Move the localStorage read into a post-mount useEffect, defaulting state to the SSR-compatible 'all'/'priority' on first render. Add a prefsLoaded flag so persist effects skip the initial render and don't overwrite saved values with defaults. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73087e9705
commit
868a53c2ed
1 changed files with 30 additions and 19 deletions
|
|
@ -197,29 +197,40 @@ function SortablePbiRow({
|
|||
export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||
const { selectedPbiId, selectPbi } = useSelectionStore()
|
||||
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
||||
const [filterPriority, setFilterPriority] = useState<number | 'all'>(() => {
|
||||
if (typeof window === 'undefined') return 'all'
|
||||
const saved = localStorage.getItem('scrum4me:pbi_filter_priority')
|
||||
if (!saved || saved === 'all') return 'all'
|
||||
const n = parseInt(saved, 10)
|
||||
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : 'all'
|
||||
})
|
||||
const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>(() => {
|
||||
if (typeof window === 'undefined') return 'all'
|
||||
const saved = localStorage.getItem('scrum4me:pbi_filter_status')
|
||||
return saved === 'ready' || saved === 'blocked' || saved === 'done' ? saved : 'all'
|
||||
})
|
||||
const [sortMode, setSortMode] = useState<SortMode>(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:pbi_sort') : null
|
||||
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
|
||||
})
|
||||
// Defaults match SSR; persisted values applied post-mount in the loader effect below.
|
||||
// This avoids hydration mismatch when localStorage holds non-default values.
|
||||
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
|
||||
const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>('all')
|
||||
const [sortMode, setSortMode] = useState<SortMode>('priority')
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode])
|
||||
useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority])
|
||||
useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus])
|
||||
// Load persisted preferences once after mount (client-only).
|
||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
||||
useEffect(() => {
|
||||
const savedSort = localStorage.getItem('scrum4me:pbi_sort')
|
||||
if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSortMode(savedSort)
|
||||
}
|
||||
const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority')
|
||||
if (savedPriority && savedPriority !== 'all') {
|
||||
const n = parseInt(savedPriority, 10)
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
|
||||
}
|
||||
const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status')
|
||||
if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') {
|
||||
setFilterStatus(savedStatus)
|
||||
}
|
||||
setPrefsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Persist on change, but skip the initial render so we don't overwrite saved values with defaults.
|
||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded])
|
||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
|
||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded])
|
||||
|
||||
// Sync server data into store — use stable string dep to avoid infinite loop
|
||||
const pbiIdKey = pbis.map(p => p.id).join(',')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue