fix(frontend): defer date and localStorage reads to post-mount to fix hydration mismatches

This commit is contained in:
devilreef 2026-04-30 10:08:15 +06:00
parent 9f9c2148b4
commit 8d00c10b9f
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y

View file

@ -23,24 +23,30 @@ const lsSet = (k: string, v: string) => {
};
export function HeroPlayer({ initialStations }: Props) {
// ssr-safe defaults; localStorage + Date reads happen post-mount to avoid
// hydration mismatches (#418, #423, #425).
const [stations, setStations] = useState<Station[]>(initialStations);
const [selectedId, setSelectedId] = useState<string>(() => {
const saved = lsGet('denpa:station');
if (saved && initialStations.some((s) => s.id === saved)) return saved;
return initialStations[0]?.id ?? '';
});
const [format, setFormat] = useState<Format>(() => (lsGet('denpa:format') === 'opus' ? 'opus' : 'mp3'));
const [vol, setVol] = useState<number>(() => {
const v = parseFloat(lsGet('denpa:vol') ?? '0.72');
return isNaN(v) ? 0.72 : Math.max(0, Math.min(1, v));
});
const [selectedId, setSelectedId] = useState<string>(initialStations[0]?.id ?? '');
const [format, setFormat] = useState<Format>('mp3');
const [vol, setVol] = useState<number>(0.72);
const [muted, setMuted] = useState(false);
const [playing, setPlaying] = useState(false);
const [now, setNow] = useState<NowPlaying | null>(null);
const [listeners, setListeners] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [copyState, setCopyState] = useState<'idle' | 'ok'>('idle');
const [clockNow, setClockNow] = useState(() => new Date());
const [clockText, setClockText] = useState<{ date: string; time: string }>({ date: '', time: '' });
// restore persisted UI state once on the client
useEffect(() => {
const savedStation = lsGet('denpa:station');
if (savedStation && initialStations.some((s) => s.id === savedStation)) {
setSelectedId(savedStation);
}
if (lsGet('denpa:format') === 'opus') setFormat('opus');
const savedVol = parseFloat(lsGet('denpa:vol') ?? '');
if (!isNaN(savedVol)) setVol(Math.max(0, Math.min(1, savedVol)));
}, [initialStations]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const station = stations.find((s) => s.id === selectedId);
@ -155,9 +161,14 @@ export function HeroPlayer({ initialStations }: Props) {
return () => clearInterval(id);
}, [now?.started_at]);
// wall clock for header
// wall clock for header — only ticks after mount so ssr/hydrate match (empty strings)
useEffect(() => {
const id = setInterval(() => setClockNow(new Date()), 1000);
const tick = () => {
const d = new Date();
setClockText({ date: fmtJpDate(d), time: fmtJpTime(d) });
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
@ -238,8 +249,8 @@ export function HeroPlayer({ initialStations }: Props) {
tune in.<br/>tune out.<br/>melt yr brain {'<3'}
</div>
<div className="hero-status">
<div>{fmtJpDate(clockNow)}</div>
<div>{fmtJpTime(clockNow)}</div>
<div>{clockText.date || ' '}</div>
<div>{clockText.time || ' '}</div>
<div className="live">[<span className="blink">{playing ? 'REC' : 'OFF'}</span>] {playing ? 'LIVE' : 'PAUSED'} · {listeners ?? '—'} listening</div>
</div>
</div>