diff --git a/eslint.config.mjs b/eslint.config.mjs index 69f7122..e3e4385 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,13 +30,16 @@ const eslintConfig = [ rules: { "no-undef": "error", "no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], - // Next 16 turned these React-Compiler-era rules on as errors. This app's - // hits are deliberate: setState-in-effect is the localStorage/hydration - // sync pattern (theme, username, count-up), and the ref reads feed - // imperative Leaflet/PhotoSphere containers that render nothing. Kept - // visible as warnings; revisit if the React Compiler is ever adopted. - "react-hooks/set-state-in-effect": "warn", - "react-hooks/refs": "warn", + // Next 16 turns these React-Compiler-era rules on as errors, and the + // code now satisfies them: browser-stored preferences are read through + // useSyncExternalStore (lib/use-stored-value.js) rather than seeded in + // an effect, and callback props reach imperative Leaflet/PhotoSphere + // handlers through useEffectEvent rather than refs written in render. + // The one ref written during render (a data snapshot in + // debug/coverage/CoverageMap.js) carries an inline disable with its + // reason. Kept as errors so the patterns do not creep back. + "react-hooks/set-state-in-effect": "error", + "react-hooks/refs": "error", }, }, ]; diff --git a/src/app/components/DailyCard.js b/src/app/components/DailyCard.js index b18ff37..c055a55 100644 --- a/src/app/components/DailyCard.js +++ b/src/app/components/DailyCard.js @@ -1,17 +1,22 @@ "use client"; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { AlertCircle, ArrowRight, Calendar, Check, Share2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { dailyDay, dailyNumber } from '../../lib/daily-calendar'; -import { getDailyProgress, currentStreak } from '../../lib/daily-progress'; +import { getDailyProgress, watchDailyProgress, currentStreak } from '../../lib/daily-progress'; +import { useStoredValue } from '../../lib/use-stored-value'; import { buildDailyShareText, shareText } from '../../lib/share'; import { formatDistance } from '../../lib/game'; const MAX_POINTS = 5; +// The day never changes under an open page in a way worth subscribing to; a +// reload at midnight is the refresh. +const subscribeNothing = () => () => {}; + /** * The home page's door to today's daily challenge. * @@ -22,26 +27,19 @@ const MAX_POINTS = 5; * @param {Function} props.onPlayClick Same interception hook as the region rows. */ export default function DailyCard({ onPlayClick }) { - const [state, setState] = useState(null); const [shareState, setShareState] = useState(null); - useEffect(() => { - const today = dailyDay(); - const progress = getDailyProgress(); - setState({ - today, - number: dailyNumber(today), - streak: currentStreak(progress, today), - played: progress?.day === today ? progress : null, - }); - }, []); + // Today's day is browser-only knowledge: the page is prerendered, and a day + // computed at build time would be frozen into the HTML and disagree with + // the client on every later day. Read through the same store hook as the + // record, with null as the server value, so nothing dated renders until + // the browser says what day it is. + const today = useStoredValue(dailyDay, subscribeNothing, null); + const progress = useStoredValue(getDailyProgress, watchDailyProgress, null); - // Everything below comes from `state` alone. The page is prerendered, so a - // fallback computed at render time would freeze the build day's number into - // the HTML and mismatch the client's on every later day. - const number = state?.number ?? null; - const played = state?.played ?? null; - const streak = state?.streak ?? 0; + const number = today ? dailyNumber(today) : null; + const played = today && progress?.day === today ? progress : null; + const streak = today ? currentStreak(progress, today) : 0; const shareLabel = shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share'; const ShareIcon = shareState === 'copied' ? Check : shareState === 'failed' ? AlertCircle : Share2; const shareStatus = diff --git a/src/app/components/FirstRoundHint.js b/src/app/components/FirstRoundHint.js index 4836ec9..5fdef89 100644 --- a/src/app/components/FirstRoundHint.js +++ b/src/app/components/FirstRoundHint.js @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { X } from 'lucide-react'; - -const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen'; +import { getHintSeen, setHintSeen, watchHintSeen } from '../../lib/first-round-hint'; +import { useStoredValue } from '../../lib/use-stored-value'; /** * One-time how-to-play banner, rendered into the panorama pane's top row. All @@ -21,30 +21,18 @@ const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen'; * @param {boolean} props.hasGuess True once a guess pin exists this round. */ export default function FirstRoundHint({ hasGuess }) { - const [visible, setVisible] = useState(false); + // Treated as seen on the server, so the banner appears only once the + // browser has said it has not been: a frame late beats a hydration mismatch. + const seen = useStoredValue(getHintSeen, watchHintSeen, true); - // Read in an effect: localStorage does not exist on the server, and the - // banner appearing a frame late beats a hydration mismatch. + // Placing a pin proves the hint has been understood. Recorded, not just + // render-hidden on `hasGuess`: the next round clears the guess, and an + // already-understood banner must not come back. useEffect(() => { - if (!localStorage.getItem(HINT_STORAGE_KEY)) setVisible(true); - }, []); - - const dismiss = () => { - setVisible(false); - localStorage.setItem(HINT_STORAGE_KEY, '1'); - }; - - // Placing a pin proves the hint has been understood. State must flip too, - // not just render-hide on `hasGuess`: the next round clears the guess, and - // an already-understood banner must not come back. - useEffect(() => { - if (hasGuess) { - setVisible(false); - localStorage.setItem(HINT_STORAGE_KEY, '1'); - } + if (hasGuess) setHintSeen(); }, [hasGuess]); - if (!visible || hasGuess) return null; + if (seen || hasGuess) return null; return ( // role="status" rather than "note": the banner appears after hydration, so @@ -64,7 +52,7 @@ export default function FirstRoundHint({ hasGuess }) { panorama drag instead of dismissing the hint. */}