From 3c91952e5c1b95323babcaebbcc5fe929d0e98b7 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 24 Feb 2026 21:35:31 -0600 Subject: [PATCH 1/3] Fix theme flicker by using useLayoutEffect for CSS var injection useEffect fires after paint, causing a one-frame flash of old colors when switching themes. useLayoutEffect fires synchronously before paint, eliminating the flicker. Also keep document.documentElement.className in sync with the active theme. --- src/components/AppProvider.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 69970f45..39184f8c 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect } from 'react'; +import { ReactNode, useLayoutEffect } from 'react'; import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; @@ -105,7 +105,7 @@ export function AppProvider(props: AppProviderProps) { * and listens for changes to prefers-color-scheme. */ function useApplyTheme(theme: Theme) { - useEffect(() => { + useLayoutEffect(() => { function apply() { const resolved = resolveTheme(theme); const tokens = themes[resolved] ?? themes.dark; @@ -118,6 +118,7 @@ function useApplyTheme(theme: Theme) { document.head.appendChild(el); } el.textContent = css; + document.documentElement.className = resolved; // Now that CSS variables are set, the inline body background from // theme.js is no longer needed — bg-background will resolve correctly. document.body.removeAttribute('style'); From 2e6beb3c23fff0cd75b49e86180e0c887da2e0c6 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 24 Feb 2026 22:01:33 -0600 Subject: [PATCH 2/3] Fix settings toggles being overwritten by stale sync - Advance lastSyncedTimestamp when skipping due to recentlyWritten() so the same snapshot cannot be re-applied once the write window expires - Use pendingSettings ref in useEncryptedSettings to accumulate rapid mutations instead of reading stale cache data - Fix ContentSettings handleToggle to not merge against stale nostrSettings --- src/components/ContentSettings.tsx | 18 ++++-------------- src/components/NostrSync.tsx | 3 +++ src/hooks/useEncryptedSettings.ts | 14 ++++++++++++-- src/hooks/useTheme.ts | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index 1474b662..b9a4ca3d 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -236,18 +236,13 @@ function KindBadge({ kind }: { kind: number }) { function SubKindRow({ sub, parentEnabled }: { sub: SubKindDef; parentEnabled: boolean }) { const { feedSettings, updateFeedSettings } = useFeedSettings(); - const { updateSettings, settings: nostrSettings } = useEncryptedSettings(); + const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const handleToggle = async (key: string, value: boolean) => { updateFeedSettings({ [key]: value }); if (user) { - // Merge against the Nostr-cached feed settings (most up-to-date persisted - // state) rather than the local React state, which may lag behind when - // multiple toggles fire in quick succession. - const baseFeed = nostrSettings?.feedSettings ?? feedSettings; - const updatedFeedSettings = { ...baseFeed, [key]: value }; - await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings }); + await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } }); } }; @@ -284,7 +279,7 @@ function SubKindRow({ sub, parentEnabled }: { sub: SubKindDef; parentEnabled: bo function ContentTypeRow({ def }: { def: ExtraKindDef }) { const { feedSettings, updateFeedSettings } = useFeedSettings(); - const { updateSettings, settings: nostrSettings } = useEncryptedSettings(); + const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const icon = ICONS[def.route ?? String(def.kind)] ?? ; const hasSubKinds = !!def.subKinds; @@ -293,12 +288,7 @@ function ContentTypeRow({ def }: { def: ExtraKindDef }) { const handleToggle = async (key: string, value: boolean) => { updateFeedSettings({ [key]: value }); if (user) { - // Merge against the Nostr-cached feed settings (most up-to-date persisted - // state) rather than the local React state, which may lag behind when - // multiple toggles fire in quick succession. - const baseFeed = nostrSettings?.feedSettings ?? feedSettings; - const updatedFeedSettings = { ...baseFeed, [key]: value }; - await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings }); + await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } }); } }; diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index fc497f0a..94eae7b4 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -94,6 +94,9 @@ export function NostrSync() { // the immediate write window, e.g. before the new event propagates back). if (recentlyWritten()) { console.log('Skipping settings sync - recent write'); + // Advance the cursor so this snapshot is never re-applied once the write + // window expires and the effect fires again. + lastSyncedTimestamp.current = remoteSync; return; } diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts index beb395cf..8c1765f6 100644 --- a/src/hooks/useEncryptedSettings.ts +++ b/src/hooks/useEncryptedSettings.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { NostrFilter } from '@nostrify/nostrify'; @@ -117,19 +117,27 @@ export function useEncryptedSettings() { refetchOnReconnect: false, }); + // Tracks the latest optimistic settings so rapid successive mutations + // don't overwrite each other by reading stale cache data. + const pendingSettings = useRef(null); + // Update settings const updateSettings = useMutation({ mutationFn: async (patch: Partial) => { if (!user) throw new Error('User not logged in'); if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer'); - const currentSettings = settings.data || {}; + // Use the latest pending settings if available, otherwise fall back to cache. + const currentSettings = pendingSettings.current ?? settings.data ?? {}; const updatedSettings: EncryptedSettings = { ...currentSettings, ...patch, lastSync: Date.now(), }; + // Optimistically track so the next rapid mutation sees this state immediately + pendingSettings.current = updatedSettings; + // Encrypt the settings const plaintext = JSON.stringify(updatedSettings); const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext); @@ -169,6 +177,8 @@ export function useEncryptedSettings() { onSuccess: ({ updatedSettings, signedEvent }) => { queryClient.setQueryData(['encryptedSettings', user?.pubkey], signedEvent); queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings); + // Cache is now up to date — pending ref no longer needed + pendingSettings.current = null; }, }); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index b514eec0..90b01932 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -3,6 +3,7 @@ import { useAppContext } from "@/hooks/useAppContext"; import { useEncryptedSettings } from "@/hooks/useEncryptedSettings"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useRef, useCallback } from "react"; +import { themes, buildThemeCss, resolveTheme } from "@/themes"; /** * Hook to get and set the active theme @@ -15,6 +16,26 @@ export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } { const debounceTimer = useRef(); const setTheme = useCallback((theme: Theme) => { + // Disable all transitions while swapping theme to prevent animated flicker + const noTransition = document.createElement('style'); + noTransition.textContent = '*, *::before, *::after { transition: none !important; }'; + document.head.appendChild(noTransition); + + // Apply CSS vars synchronously before React re-renders to eliminate flicker + const resolved = resolveTheme(theme); + const css = buildThemeCss(themes[resolved] ?? themes.dark); + let el = document.getElementById('theme-vars') as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = 'theme-vars'; + document.head.appendChild(el); + } + el.textContent = css; + document.documentElement.className = resolved; + + // Re-enable transitions after the browser has painted the new theme + requestAnimationFrame(() => noTransition.remove()); + // Update local immediately updateConfig((currentConfig) => ({ ...currentConfig, From c55c075434bb3a5f46d8063b7183a16f582d3341 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 24 Feb 2026 22:02:41 -0600 Subject: [PATCH 3/3] Fix notifications re-triggering unread after being marked as read Use an optimistic local cursor ref that updates immediately on markAsRead, so newNotificationIds collapses to empty before the query cache catches up. This prevents the NotificationsPage effect from re-calling markAsRead in a loop. --- src/hooks/useNotifications.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index beffd35e..56fe279d 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { Capacitor } from '@capacitor/core'; @@ -175,10 +175,18 @@ export function useNotifications(): NotificationData { }, [data?.pages]); // Only use cursor if settings have actually loaded, otherwise null - const notificationsCursor = settings !== undefined && settings !== null + const remoteCursor = settings !== undefined && settings !== null ? (settings.notificationsCursor ?? 0) : null; + // Optimistic local cursor — updated immediately on markAsRead so that + // newNotificationIds collapses to empty before the query cache catches up, + // preventing the NotificationsPage effect from re-triggering markAsRead. + const optimisticCursor = useRef(null); + const notificationsCursor = optimisticCursor.current !== null + ? Math.max(optimisticCursor.current, remoteCursor ?? 0) + : remoteCursor; + // Build set of unread notification IDs const newNotificationIds = useMemo(() => { if (notificationsCursor === null) return new Set(); @@ -199,12 +207,18 @@ export function useNotifications(): NotificationData { if (newestTimestamp <= notificationsCursor) return; + // Update optimistic cursor immediately so unread state clears before + // the query cache updates, preventing re-trigger loops. + optimisticCursor.current = newestTimestamp; + try { await updateSettings.mutateAsync({ notificationsCursor: newestTimestamp, }); } catch (error) { console.error('Failed to mark notifications as read:', error); + // Roll back optimistic cursor on failure + optimisticCursor.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.pubkey, items.length, notificationsCursor]);