diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 287aab61..762a48ef 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -10,6 +10,16 @@ export const THEME = { // index.html reads it synchronously to pick the splash background before first paint. export const THEME_LOCALSTORAGE_KEY = "theme"; +// Default values returned by the getters below when a preference hasn't been set. Single source of +// truth, also used by the PrefCache (see PrefCache.jsx) for its loading-state placeholder. +export const PREF_DEFAULTS = { + sound: "ding", + minPriority: 1, + deleteAfter: 604800, // one week + theme: THEME.SYSTEM, + webPushEnabled: false, +}; + const mirrorThemeToLocalStorage = (value) => { try { localStorage.setItem(THEME_LOCALSTORAGE_KEY, value); @@ -30,7 +40,7 @@ class Prefs { async sound() { const sound = await this.db.prefs.get("sound"); - return sound ? sound.value : "ding"; + return sound ? sound.value : PREF_DEFAULTS.sound; } async setMinPriority(minPriority) { @@ -39,7 +49,7 @@ class Prefs { async minPriority() { const minPriority = await this.db.prefs.get("minPriority"); - return minPriority ? Number(minPriority.value) : 1; + return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority; } async setDeleteAfter(deleteAfter) { @@ -48,12 +58,12 @@ class Prefs { async deleteAfter() { const deleteAfter = await this.db.prefs.get("deleteAfter"); - return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week + return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter; } async webPushEnabled() { const webPushEnabled = await this.db.prefs.get("webPushEnabled"); - return webPushEnabled?.value; + return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled; } async setWebPushEnabled(enabled) { @@ -62,7 +72,7 @@ class Prefs { async theme() { const theme = await this.db.prefs.get("theme"); - const value = theme?.value ?? THEME.SYSTEM; + const value = theme?.value ?? PREF_DEFAULTS.theme; // Mirror to localStorage so the inline script in index.html can pick the splash background // synchronously before first paint. Self-heals for users who set their theme before the // mirror existed. diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index cf22158e..e6aaeed5 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -28,6 +28,7 @@ import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; import session from "../app/Session"; import AccountContext from "./AccountContext"; +import { PrefCacheProvider } from "./PrefCache"; import hideSplash from "../app/splash"; initI18n(); @@ -148,27 +149,29 @@ const Layout = () => { }, [subscriptions]); return ( - - setMobileDrawerOpen(!mobileDrawerOpen)} /> - setMobileDrawerOpen(!mobileDrawerOpen)} - onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} - /> -
- - + + setMobileDrawerOpen(!mobileDrawerOpen)} /> + setMobileDrawerOpen(!mobileDrawerOpen)} + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} /> -
- -
+
+ + +
+ + + ); }; diff --git a/web/src/components/PrefCache.jsx b/web/src/components/PrefCache.jsx new file mode 100644 index 00000000..7fe68438 --- /dev/null +++ b/web/src/components/PrefCache.jsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { createContext, useContext } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; +import prefs, { PREF_DEFAULTS } from "../app/Prefs"; + +// A render-time CACHE of the user's preferences -- NOT the source of truth. The source of truth is +// the `prefs` table in IndexedDB, always read/written via Prefs.js. This context preloads all prefs +// once (mounted by Layout, above the routed content), so the Settings page renders its controls +// with the right values immediately instead of each one flickering in as its own IndexedDB read +// resolves. By the time you navigate to Settings the query has already resolved. +const PrefCacheContext = createContext(undefined); + +export const PrefCacheProvider = ({ children }) => { + const cache = useLiveQuery(async () => ({ + sound: await prefs.sound(), + minPriority: await prefs.minPriority(), + deleteAfter: await prefs.deleteAfter(), + theme: await prefs.theme(), + webPushEnabled: await prefs.webPushEnabled(), + })); + return {children}; +}; + +// While the cache is still loading (only on the very first paint, mostly hidden by the splash) fall +// back to the same defaults the Prefs getters use, so controls never render with no value. +export const usePrefCache = () => useContext(PrefCacheContext) ?? PREF_DEFAULTS; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index e617deed..6b5f3e03 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -49,7 +49,8 @@ import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./Rese import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; import notifier from "../app/Notifier"; -import { useCachedPref, useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; +import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; +import { usePrefCache } from "./PrefCache"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -99,7 +100,7 @@ const Notifications = () => { const Sound = () => { const { t } = useTranslation(); const labelId = "prefSound"; - const sound = useCachedPref(() => prefs.sound(), "pref.sound", "ding"); + const { sound } = usePrefCache(); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); await maybeUpdateAccountSettings({ @@ -140,7 +141,7 @@ const Sound = () => { const MinPriority = () => { const { t } = useTranslation(); const labelId = "prefMinPriority"; - const minPriority = useCachedPref(() => prefs.minPriority(), "pref.minPriority", 1); + const { minPriority } = usePrefCache(); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); await maybeUpdateAccountSettings({ @@ -185,7 +186,7 @@ const MinPriority = () => { const DeleteAfter = () => { const { t } = useTranslation(); const labelId = "prefDeleteAfter"; - const deleteAfter = useCachedPref(() => prefs.deleteAfter(), "pref.deleteAfter", 604800); + const { deleteAfter } = usePrefCache(); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); await maybeUpdateAccountSettings({ @@ -230,7 +231,7 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); + const { theme } = usePrefCache(); const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); }; @@ -251,7 +252,7 @@ const Theme = () => { const WebPushEnabled = () => { const { t } = useTranslation(); const labelId = "prefWebPushEnabled"; - const enabled = useCachedPref(() => prefs.webPushEnabled(), "pref.webPushEnabled", false); + const { webPushEnabled: enabled } = usePrefCache(); const handleChange = async (ev) => { await prefs.setWebPushEnabled(ev.target.value); }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index a4158c87..b2502b48 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -15,39 +15,6 @@ import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events"; -/** - * Reads a preference via a Dexie live query, but renders the last known value from localStorage - * synchronously on first paint (and mirrors new values back to localStorage). This avoids the - * Settings page flicker where rows "pop in" one by one while IndexedDB resolves -- the cached value - * shows instantly and is confirmed/updated from IndexedDB in the background. - */ -export const useCachedPref = (reader, cacheKey, fallback) => { - const live = useLiveQuery(reader); - - useEffect(() => { - if (live !== undefined && live !== null) { - try { - localStorage.setItem(cacheKey, JSON.stringify(live)); - } catch (e) { - // localStorage may be unavailable (private mode); the value just isn't cached for next time - } - } - }, [live, cacheKey]); - - if (live !== undefined && live !== null) { - return live; - } - try { - const cached = localStorage.getItem(cacheKey); - if (cached !== null) { - return JSON.parse(cached); - } - } catch (e) { - // ignore parse/storage errors and fall back - } - return fallback; -}; - /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection * state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead