From 30cfe63e5ff527cac66f7037a7391ef2430ef06b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:19:58 +0700 Subject: [PATCH] refactor(client): read browser preferences through one store hook Every localStorage-backed preference (name, theme, sound, last region, daily record, hint seen) now goes through lib/storage.js, which never throws and notifies watchers in this tab and others, and components read it with useStoredValue over useSyncExternalStore instead of seeding state in an effect. That fixes the breakpoint pair of theme toggles disagreeing after a click, takes the bare localStorage calls out of the username module and the first-round hint (a blocked-storage browser threw inside the home page's landing effect), and lets the daily replay derive from the stored record rather than from six setState calls in the mount effect. Callback props reach the imperative Leaflet and PhotoSphere handlers through useEffectEvent. The leaderboard modal fetches from the action that changed the board, the search box resets on the prop's edge, the count-up derives its idle values, and the username form mounts with the dialog so it needs no re-seeding. The react-hooks rules that flagged twenty-one warnings are now errors, and nothing violates them. --- eslint.config.mjs | 17 +++--- src/app/components/DailyCard.js | 36 ++++++------- src/app/components/FirstRoundHint.js | 36 +++++-------- src/app/components/GameClient.js | 69 +++++++++++++------------ src/app/components/LeaderboardModal.js | 30 +++++++---- src/app/components/LeafletMap.js | 46 ++++++----------- src/app/components/MapSearchBox.js | 17 +++--- src/app/components/PanoramaViewer.js | 21 ++++---- src/app/components/RegionPicker.js | 24 ++++----- src/app/components/SoundToggle.js | 24 +++------ src/app/components/ThemeToggle.js | 34 ++++++------ src/app/components/UsernameModal.js | 70 +++++++++++++------------ src/app/debug/coverage/CoverageMap.js | 20 ++++---- src/app/debug/coverage/page.js | 17 ++++-- src/app/page.js | 37 +++++++------- src/lib/daily-progress.js | 31 +++++++---- src/lib/first-round-hint.js | 18 +++++++ src/lib/last-region.js | 22 +++----- src/lib/storage.js | 71 ++++++++++++++++++++++++++ src/lib/theme.js | 30 +++++------ src/lib/use-count-up.js | 38 ++++++-------- src/lib/use-stored-value.js | 25 +++++++++ src/lib/username.js | 14 +++-- tests/storage.test.js | 22 ++++++++ 24 files changed, 448 insertions(+), 321 deletions(-) create mode 100644 src/lib/first-round-hint.js create mode 100644 src/lib/storage.js create mode 100644 src/lib/use-stored-value.js create mode 100644 tests/storage.test.js 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. */}