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