From 44e6c0a9c48660a77c80f0c3ba84eab12474a2ff Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 20:21:46 +0200 Subject: [PATCH 1/3] fix: restore synced local tweaks --- .../content/UploadedSableCssContent.tsx | 9 +- .../cosmetics/ThemeCatalogSettings.test.tsx | 136 ++++++++++++ .../cosmetics/ThemeCatalogSettings.tsx | 43 +++- .../settings/cosmetics/ThemeImportModal.tsx | 1 + src/app/features/settings/general/General.tsx | 3 +- src/app/hooks/useSettingsSync.test.tsx | 204 +++++++++++++++++- src/app/hooks/useSettingsSync.ts | 89 +++++--- src/app/pages/ThemeManager.test.tsx | 30 +++ src/app/pages/ThemeManager.tsx | 14 +- src/app/state/settings.ts | 10 +- src/app/theme/processThemeImport.test.ts | 23 ++ src/app/theme/processThemeImport.ts | 3 + src/app/theme/syncedTweakCss.test.ts | 87 ++++++++ src/app/theme/syncedTweakCss.ts | 65 ++++++ src/app/utils/settingsSync.test.ts | 186 ++++++++++++++++ src/app/utils/settingsSync.ts | 92 +++++++- 16 files changed, 939 insertions(+), 56 deletions(-) create mode 100644 src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx create mode 100644 src/app/theme/processThemeImport.test.ts create mode 100644 src/app/theme/syncedTweakCss.test.ts create mode 100644 src/app/theme/syncedTweakCss.ts diff --git a/src/app/components/message/content/UploadedSableCssContent.tsx b/src/app/components/message/content/UploadedSableCssContent.tsx index f77442b925..50b227a107 100644 --- a/src/app/components/message/content/UploadedSableCssContent.tsx +++ b/src/app/components/message/content/UploadedSableCssContent.tsx @@ -66,7 +66,13 @@ function UploadedTweakCard({ data }: { data: Extract favorite.fullUrl === data.fullUrl); if (existing) { return favorites.map((favorite) => - favorite.fullUrl === data.fullUrl && pinned ? { ...favorite, pinned: true } : favorite + favorite.fullUrl === data.fullUrl + ? { + ...favorite, + pinned: pinned ? true : favorite.pinned, + cssText: favorite.cssText ?? data.cssText, + } + : favorite ); } return [ @@ -77,6 +83,7 @@ function UploadedTweakCard({ data }: { data: Extract ({ + getCachedThemeCss: vi.fn<() => Promise>(), + putCachedThemeCss: vi.fn<() => Promise>(), +})); + +type TweakFavorite = { + fullUrl: string; + displayName: string; + basename: string; + importedLocal: boolean; + cssText?: string; +}; + +const settings = { + themeRemoteFavorites: [], + themeRemoteTweakFavorites: [ + { + fullUrl: 'sable-import://tweak/restored/full.sable.css', + displayName: 'Fallback name', + basename: 'restored', + importedLocal: true, + cssText: '/*\n@sable-tweak\nname: Restored tweak\n*/\n.restored {}', + }, + ] as TweakFavorite[], + themeRemoteEnabledTweakFullUrls: [], + useSystemTheme: false, + themeRemoteManualFullUrl: undefined, + themeRemoteLightFullUrl: undefined, + themeRemoteDarkFullUrl: undefined, + themeChatSableWidgetsEnabled: false, + themeChatAutoPreviewApprovedUrls: [], + themeChatAutoPreviewAnyUrl: false, +}; + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: keyof typeof settings) => [settings[key], vi.fn()], +})); +vi.mock('$state/settings', () => ({ + settingsAtom: {}, + getSettings: () => ({ + iconCompactSizePx: 16, + iconInlineSizePx: 20, + iconToolbarSizePx: 24, + iconEmptySizePx: 32, + }), +})); +vi.mock('$hooks/useClientConfig', () => ({ useClientConfig: () => ({}) })); +vi.mock('$utils/share', () => ({ shareText: vi.fn() })); +vi.mock('../../../theme/cache', () => ({ + getCachedThemeCss, + putCachedThemeCss, +})); + +import { ThemeCatalogSettings } from './ThemeCatalogSettings'; + +describe('ThemeCatalogSettings saved tweaks', () => { + beforeEach(() => { + getCachedThemeCss.mockRejectedValue(new Error('no cache')); + putCachedThemeCss.mockRejectedValue(new Error('no cache')); + }); + + afterEach(() => { + settings.themeRemoteTweakFavorites = [ + { + fullUrl: 'sable-import://tweak/restored/full.sable.css', + displayName: 'Fallback name', + basename: 'restored', + importedLocal: true, + cssText: '/*\n@sable-tweak\nname: Restored tweak\n*/\n.restored {}', + }, + ]; + getCachedThemeCss.mockReset().mockRejectedValue(new Error('no cache')); + putCachedThemeCss.mockReset().mockRejectedValue(new Error('no cache')); + }); + + it('lists restored embedded local CSS when the cache is unavailable', async () => { + render( + + + + ); + + fireEvent.click(screen.getByText('Tweaks (1)')); + expect(await screen.findByText('Restored tweak')).toBeInTheDocument(); + }); + + it('shows zero and migration guidance for a fresh body-less legacy tweak', async () => { + settings.themeRemoteTweakFavorites = [ + { + fullUrl: 'sable-import://tweak/legacy/full.sable.css', + displayName: 'Legacy', + basename: 'legacy', + importedLocal: true, + }, + ]; + render( + + + + ); + expect(screen.getByText('Tweaks (0)')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Tweaks (0)')); + expect(await screen.findByText(/waiting to migrate/)).toBeInTheDocument(); + }); + + it('counts a cache-resolved legacy tweak without migration guidance', async () => { + settings.themeRemoteTweakFavorites = [ + { + fullUrl: 'sable-import://tweak/legacy/full.sable.css', + displayName: 'Legacy', + basename: 'legacy', + importedLocal: true, + }, + ]; + getCachedThemeCss.mockResolvedValue('/*\n@sable-tweak\nname: Cached legacy\n*/\n.cached {}'); + render( + + + + ); + fireEvent.click(screen.getByText('Tweaks (0)')); + expect(await screen.findByText('Cached legacy')).toBeInTheDocument(); + expect(screen.getByText('Tweaks (1)')).toBeInTheDocument(); + expect(screen.queryByText(/waiting to migrate/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index 7b944513b8..0111c6ec5a 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -39,7 +39,12 @@ import { type ThemePair, type TweakCatalogEntry, } from '../../../theme/catalog'; -import { isLocalImportBundledUrl, isLocalImportThemeUrl } from '../../../theme/localImportUrls'; +import { + isLocalImportBundledUrl, + isLocalImportThemeUrl, + isLocalImportTweakUrl, +} from '../../../theme/localImportUrls'; +import { resolveSavedTweakCss } from '../../../theme/syncedTweakCss'; import { isThirdPartyThemeUrl } from '../../../theme/themeApproval'; import { themeCatalogListingBaseUrl } from '../../../theme/catalogDefaults'; import { @@ -579,14 +584,17 @@ export function ThemeCatalogSettings({ }); const localTweaksQuery = useQuery({ - queryKey: ['theme-local-tweaks', tweakFavorites.map((f) => f.fullUrl).join('|')], + queryKey: [ + 'theme-local-tweaks', + tweakFavorites.map((f) => `${f.fullUrl}:${f.cssText ?? ''}`).join('|'), + ], enabled: showSavedLibrary && tweakFavorites.length > 0, staleTime: 10 * 60_000, queryFn: async (): Promise => { const rows = await Promise.all( tweakFavorites.map(async (fav) => { try { - let text = (await getCachedThemeCss(fav.fullUrl)) ?? ''; + let text = await resolveSavedTweakCss(fav); if (!isLocalImportBundledUrl(fav.fullUrl)) { try { const res = await fetch(fav.fullUrl, { mode: 'cors' }); @@ -616,6 +624,23 @@ export function ThemeCatalogSettings({ return rows.filter((r): r is LocalTweakRow => Boolean(r)); }, }); + const resolvedTweakUrls = new Set( + localTweaksQuery.isSuccess ? localTweaksQuery.data.map((row) => row.fullUrl) : [] + ); + const provisionalTweakCount = tweakFavorites.filter( + (favorite) => !isLocalImportTweakUrl(favorite.fullUrl) || Boolean(favorite.cssText) + ).length; + const savedTweakCount = localTweaksQuery.isSuccess + ? resolvedTweakUrls.size + : provisionalTweakCount; + const unresolvedLegacyTweakCount = localTweaksQuery.isSuccess + ? tweakFavorites.filter( + (favorite) => + isLocalImportTweakUrl(favorite.fullUrl) && + !favorite.cssText && + !resolvedTweakUrls.has(favorite.fullUrl) + ).length + : 0; const removeFavorite = useCallback( (fullUrl: string) => { @@ -1103,7 +1128,7 @@ export function ThemeCatalogSettings({ radii="Pill" onClick={() => setSavedSection('tweaks')} > - Tweaks ({tweakFavorites.length}) + Tweaks ({savedTweakCount}) @@ -1263,9 +1288,17 @@ export function ThemeCatalogSettings({ paddingRight: toRem(4), }} > + {localTweaksQuery.data.length > 0 && unresolvedLegacyTweakCount > 0 && ( + + {unresolvedLegacyTweakCount} saved local tweak + {unresolvedLegacyTweakCount === 1 ? ' is' : 's are'} waiting to migrate. + + )} {localTweaksQuery.data.length === 0 ? ( - Could not load tweak CSS. Check the URL or your connection. + {unresolvedLegacyTweakCount === tweakFavorites.length + ? 'Some saved local tweaks are waiting to migrate. Open Sable on a device that still has them to finish syncing.' + : 'Could not load tweak CSS. Check the URL or your connection.'} ) : ( localTweaksQuery.data.map((row) => { diff --git a/src/app/features/settings/cosmetics/ThemeImportModal.tsx b/src/app/features/settings/cosmetics/ThemeImportModal.tsx index 7a4a8bf9ad..b1a41f5f67 100644 --- a/src/app/features/settings/cosmetics/ThemeImportModal.tsx +++ b/src/app/features/settings/cosmetics/ThemeImportModal.tsx @@ -96,6 +96,7 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) { basename: r.basename, pinned: true, importedLocal: r.importedLocal, + cssText: r.cssText, }; const nextEnabled = enabledTweakFullUrls.includes(r.fullUrl) ? [...enabledTweakFullUrls] diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b9b32ab850..e25c941f40 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1424,6 +1424,7 @@ function Sync() { ? `Last synced at ${dayjs(lastSynced).format('HH:mm:ss')}` : 'Not yet synced this session', syncing: 'Syncing…', + partial: 'Settings synced, but some local tweaks were too large to sync.', error: 'Sync failed — will retry on next change', }; @@ -1456,7 +1457,7 @@ function Sync() { } /> {syncEnabled && ( diff --git a/src/app/hooks/useSettingsSync.test.tsx b/src/app/hooks/useSettingsSync.test.tsx index b2bef4f325..1074290a61 100644 --- a/src/app/hooks/useSettingsSync.test.tsx +++ b/src/app/hooks/useSettingsSync.test.tsx @@ -4,9 +4,20 @@ import { createStore, Provider } from 'jotai'; import { createElement, type ReactNode } from 'react'; import { settingsAtom, getSettings } from '$state/settings'; -import { SETTINGS_SYNC_VERSION } from '$utils/settingsSync'; +import { deserializeFromSync, SETTINGS_SYNC_VERSION } from '$utils/settingsSync'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; +const { getCachedThemeCss, putCachedThemeCss } = vi.hoisted(() => ({ + getCachedThemeCss: vi + .fn<(url: string) => Promise>() + .mockResolvedValue(undefined), + putCachedThemeCss: vi + .fn<(url: string, cssText: string) => Promise>() + .mockResolvedValue(undefined), +})); + +vi.mock('../theme/cache', () => ({ getCachedThemeCss, putCachedThemeCss })); + import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom, @@ -136,6 +147,55 @@ describe('useSettingsSyncEffect — sync enabled on mount', () => { expect(store.get(settingsAtom).twitterEmoji).toBe(false); }); + it('best-effort hydrates embedded local tweak CSS without delaying settings application', async () => { + const tweakUrl = 'sable-import://tweak/restored/full.sable.css'; + mockMx.getAccountData.mockReturnValueOnce({ + getContent: () => ({ + v: SETTINGS_SYNC_VERSION, + settings: { + themeRemoteTweakFavorites: [ + { + fullUrl: tweakUrl, + displayName: 'Restored', + basename: 'restored', + cssText: 'body {}', + }, + ], + }, + }), + }); + const store = makeStore({ settingsSyncEnabled: true }); + + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + expect(store.get(settingsAtom).themeRemoteTweakFavorites[0]?.cssText).toBe('body {}'); + await vi.waitFor(() => expect(putCachedThemeCss).toHaveBeenCalledWith(tweakUrl, 'body {}')); + }); + + it('keeps an oversized source-only local tweak on initial remote settings load', () => { + const oversizedUrl = 'sable-import://tweak/oversized/full.sable.css'; + mockMx.getAccountData.mockReturnValueOnce({ + getContent: () => ({ + v: SETTINGS_SYNC_VERSION, + settings: { themeRemoteTweakFavorites: [], themeRemoteEnabledTweakFullUrls: [] }, + }), + }); + const store = makeStore({ + settingsSyncEnabled: true, + themeRemoteTweakFavorites: [ + { + fullUrl: oversizedUrl, + displayName: 'Oversized', + basename: 'oversized', + cssText: 'x'.repeat(256 * 1024 + 1), + }, + ], + themeRemoteEnabledTweakFullUrls: [oversizedUrl], + }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + expect(store.get(settingsAtom).themeRemoteEnabledTweakFullUrls).toContain(oversizedUrl); + }); + it('sets lastSynced after loading from account data on mount', () => { const remoteContent = { v: SETTINGS_SYNC_VERSION, settings: { twitterEmoji: false } }; mockMx.getAccountData.mockReturnValueOnce({ getContent: () => remoteContent }); @@ -172,12 +232,13 @@ describe('useSettingsSyncEffect — debounced upload', () => { vi.useRealTimers(); }); - it('uploads settings after the debounce delay', () => { + it('uploads settings after the debounce delay', async () => { const store = makeStore({ settingsSyncEnabled: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); - act(() => { + await act(async () => { vi.advanceTimersByTime(2000); + await Promise.resolve(); }); expect(mockMx.setAccountData).toHaveBeenCalledOnce(); @@ -190,12 +251,82 @@ describe('useSettingsSyncEffect — debounced upload', () => { expect(typeof content.synctoken).toBe('string'); }); - it('sets sync status to syncing while the upload is in flight', () => { + it('backfills legacy local tweak CSS from cache into the upload payload', async () => { + const tweakUrl = 'sable-import://tweak/legacy/full.sable.css'; + getCachedThemeCss.mockResolvedValueOnce('.legacy { color: purple; }'); + const store = makeStore({ + settingsSyncEnabled: true, + themeRemoteTweakFavorites: [{ fullUrl: tweakUrl, displayName: 'Legacy', basename: 'legacy' }], + }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + + const uploadedContent = mockMx.setAccountData.mock.calls[0]?.[1]; + expect(uploadedContent?.settings).toMatchObject({ + themeRemoteTweakFavorites: [{ fullUrl: tweakUrl, cssText: '.legacy { color: purple; }' }], + }); + expect( + deserializeFromSync(uploadedContent, getSettings())?.themeRemoteTweakFavorites[0]?.cssText + ).toBe('.legacy { color: purple; }'); + }); + + it('cancels a stale deferred backfill when settings change', async () => { + let resolveCache: (css: string | undefined) => void = () => undefined; + getCachedThemeCss.mockImplementationOnce( + () => new Promise((resolve) => (resolveCache = resolve)) + ); + const store = makeStore({ + settingsSyncEnabled: true, + themeRemoteTweakFavorites: [ + { + fullUrl: 'sable-import://tweak/legacy/full.sable.css', + displayName: 'Legacy', + basename: 'legacy', + }, + ], + }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + act(() => vi.advanceTimersByTime(2000)); + act(() => store.set(settingsAtom, { ...store.get(settingsAtom), twitterEmoji: false })); + resolveCache('body {}'); + await act(async () => await Promise.resolve()); + expect(mockMx.setAccountData).not.toHaveBeenCalled(); + }); + + it('cancels a deferred backfill when settings sync is disabled', async () => { + let resolveCache: (css: string | undefined) => void = () => undefined; + getCachedThemeCss.mockImplementationOnce( + () => new Promise((resolve) => (resolveCache = resolve)) + ); + const store = makeStore({ + settingsSyncEnabled: true, + themeRemoteTweakFavorites: [ + { + fullUrl: 'sable-import://tweak/legacy/full.sable.css', + displayName: 'Legacy', + basename: 'legacy', + }, + ], + }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + act(() => vi.advanceTimersByTime(2000)); + act(() => store.set(settingsAtom, { ...store.get(settingsAtom), settingsSyncEnabled: false })); + resolveCache('body {}'); + await act(async () => await Promise.resolve()); + expect(mockMx.setAccountData).not.toHaveBeenCalled(); + }); + + it('sets sync status to syncing after cache backfill while the upload is in flight', async () => { const store = makeStore({ settingsSyncEnabled: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); - act(() => { + await act(async () => { vi.advanceTimersByTime(2000); + await Promise.resolve(); }); expect(store.get(settingsSyncStatusAtom)).toBe('syncing'); @@ -214,6 +345,26 @@ describe('useSettingsSyncEffect — debounced upload', () => { expect(store.get(settingsSyncStatusAtom)).toBe('error'); }); + + it('keeps idle state after disabling sync during an in-flight request and ignores its late failure', async () => { + let rejectUpload: (error: Error) => void = () => undefined; + mockMx.setAccountData.mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectUpload = reject)) + ); + const store = makeStore({ settingsSyncEnabled: true, twitterEmoji: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + expect(store.get(settingsSyncStatusAtom)).toBe('syncing'); + act(() => store.set(settingsAtom, { ...store.get(settingsAtom), settingsSyncEnabled: false })); + expect(store.get(settingsSyncStatusAtom)).toBe('idle'); + rejectUpload(new Error('late failure')); + await act(async () => await Promise.resolve()); + expect(store.get(settingsSyncStatusAtom)).toBe('idle'); + expect(store.get(settingsAtom).twitterEmoji).toBe(true); + }); }); // Hook: echo-token loop prevention @@ -234,8 +385,9 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); // Trigger the upload. - act(() => { + await act(async () => { vi.advanceTimersByTime(2000); + await Promise.resolve(); }); // Capture the echo token that was uploaded. @@ -262,8 +414,9 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { const store = makeStore({ settingsSyncEnabled: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); - act(() => { + await act(async () => { vi.advanceTimersByTime(2000); + await Promise.resolve(); }); const uploadedContent: Record | undefined = @@ -289,13 +442,19 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { expect(lastSynced!).toBeLessThanOrEqual(after); }); - it('applies an event from another device (different or absent echo token)', () => { + it('applies an event from another device and hydrates its embedded local tweak CSS', async () => { const store = makeStore({ settingsSyncEnabled: true, twitterEmoji: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + const tweakUrl = 'sable-import://tweak/live/full.sable.css'; const remoteEvent = makeSableSettingsEvent({ v: SETTINGS_SYNC_VERSION, - settings: { twitterEmoji: false }, + settings: { + twitterEmoji: false, + themeRemoteTweakFavorites: [ + { fullUrl: tweakUrl, displayName: 'Live', basename: 'live', cssText: '.live {}' }, + ], + }, // No synctoken — definitely from another device. }); @@ -304,5 +463,32 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { }); expect(store.get(settingsAtom).twitterEmoji).toBe(false); + await vi.waitFor(() => expect(putCachedThemeCss).toHaveBeenCalledWith(tweakUrl, '.live {}')); + }); + + it('keeps an oversized source-only local tweak on a live remote update', () => { + const oversizedUrl = 'sable-import://tweak/oversized-live/full.sable.css'; + const store = makeStore({ + settingsSyncEnabled: true, + themeRemoteTweakFavorites: [ + { + fullUrl: oversizedUrl, + displayName: 'Oversized', + basename: 'oversized', + cssText: 'x'.repeat(256 * 1024 + 1), + }, + ], + themeRemoteEnabledTweakFullUrls: [oversizedUrl], + }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + act(() => { + callbackHolder.current?.( + makeSableSettingsEvent({ + v: SETTINGS_SYNC_VERSION, + settings: { themeRemoteTweakFavorites: [], themeRemoteEnabledTweakFullUrls: [] }, + }) + ); + }); + expect(store.get(settingsAtom).themeRemoteEnabledTweakFullUrls).toContain(oversizedUrl); }); }); diff --git a/src/app/hooks/useSettingsSync.ts b/src/app/hooks/useSettingsSync.ts index 090959a808..83b747c460 100644 --- a/src/app/hooks/useSettingsSync.ts +++ b/src/app/hooks/useSettingsSync.ts @@ -5,10 +5,11 @@ import type { MatrixEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAccountDataCallback } from '$hooks/useAccountDataCallback'; import { settingsAtom } from '$state/settings'; -import { deserializeFromSync, serializeForSync } from '$utils/settingsSync'; +import { deserializeFromSync, prepareSettingsForSync } from '$utils/settingsSync'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import { backfillLocalTweakCss, hydrateSyncedLocalTweakCss } from '../theme/syncedTweakCss'; -export type SyncStatus = 'idle' | 'syncing' | 'error'; +export type SyncStatus = 'idle' | 'syncing' | 'partial' | 'error'; /** Milliseconds to wait after a local settings change before uploading. */ const DEBOUNCE_MS = 2000; @@ -50,6 +51,7 @@ export function useSettingsSyncEffect(): void { const { synctoken: echoField, ...content } = event.getContent(); const merged = deserializeFromSync(content, settingsRef.current); if (merged) { + void hydrateSyncedLocalTweakCss(merged.themeRemoteTweakFavorites); if (JSON.stringify(merged) !== JSON.stringify(settingsRef.current)) { setSettings(merged); } @@ -57,29 +59,27 @@ export function useSettingsSyncEffect(): void { } }, [mx, syncEnabled, setSettings, setLastSynced]); - // Echo-detection: track the token of our last upload - // When our upload echoes back via ClientEvent.AccountData we skip applying it - // (to avoid overwriting settings that changed between upload and echo). - const pendingEchoTokenRef = useRef(null); + const uploadGenerationRef = useRef(0); + const ownEchoesRef = useRef(new Map()); // Live updates from other devices const onAccountData = useCallback( (event: MatrixEvent) => { if (event.getType() !== (CustomAccountDataEvent.SableSettings as string)) return; - if (!settingsRef.current.settingsSyncEnabled) return; - const rawContent = event.getContent(); - - // If this is the echo of our own upload, just confirm success and skip. - if ( - typeof rawContent.synctoken === 'string' && - rawContent.synctoken === pendingEchoTokenRef.current - ) { - pendingEchoTokenRef.current = null; - setLastSynced(Date.now()); - setSyncStatus('idle'); + const ownEcho = + typeof rawContent.synctoken === 'string' + ? ownEchoesRef.current.get(rawContent.synctoken) + : undefined; + if (ownEcho) { + ownEchoesRef.current.delete(rawContent.synctoken as string); + if (ownEcho.generation === uploadGenerationRef.current) { + setLastSynced(Date.now()); + setSyncStatus(ownEcho.status); + } return; } + if (!settingsRef.current.settingsSyncEnabled) return; // Strip internal synctoken field before deserializing so stale tokens from // previous sessions (stored on the homeserver) don't bypass the check above @@ -88,6 +88,7 @@ export function useSettingsSyncEffect(): void { // Otherwise it came from another device — apply it. const merged = deserializeFromSync(content, settingsRef.current); + if (merged) void hydrateSyncedLocalTweakCss(merged.themeRemoteTweakFavorites); // Skip if nothing actually changed (deserializeFromSync always returns a // new object, so compare values to avoid a spurious settings → upload loop). if (merged && JSON.stringify(merged) !== JSON.stringify(settingsRef.current)) { @@ -105,23 +106,49 @@ export function useSettingsSyncEffect(): void { // Debounced upload whenever settings change const timerRef = useRef>(); useEffect(() => { - if (!syncEnabled) return undefined; - clearTimeout(timerRef.current); + const generation = ++uploadGenerationRef.current; + if (!syncEnabled) { + setSyncStatus('idle'); + return undefined; + } + let cancelled = false; timerRef.current = setTimeout(() => { - setSyncStatus('syncing'); - const token = Math.random().toString(36).slice(2, 10); - pendingEchoTokenRef.current = token; - const content = { ...serializeForSync(settingsRef.current), synctoken: token }; - mx.setAccountData( - CustomAccountDataEvent.SableSettings, - content as Record - ).catch(() => { - pendingEchoTokenRef.current = null; - setSyncStatus('error'); - }); + void (async () => { + const current = settingsRef.current; + const themeRemoteTweakFavorites = await backfillLocalTweakCss( + current.themeRemoteTweakFavorites + ); + if ( + cancelled || + generation !== uploadGenerationRef.current || + !settingsRef.current.settingsSyncEnabled + ) + return; + const prepared = prepareSettingsForSync({ ...current, themeRemoteTweakFavorites }); + const token = Math.random().toString(36).slice(2, 10); + const completionStatus: SyncStatus = + prepared.excludedLocalTweakUrls.length > 0 ? 'partial' : 'idle'; + ownEchoesRef.current.set(token, { generation, status: completionStatus }); + setSyncStatus('syncing'); + const content = { + ...prepared.content, + synctoken: token, + }; + mx.setAccountData( + CustomAccountDataEvent.SableSettings, + content as Record + ).catch(() => { + ownEchoesRef.current.delete(token); + if (generation === uploadGenerationRef.current) setSyncStatus('error'); + }); + })(); }, DEBOUNCE_MS); - return () => clearTimeout(timerRef.current); + return () => { + cancelled = true; + if (generation === uploadGenerationRef.current) uploadGenerationRef.current += 1; + clearTimeout(timerRef.current); + }; }, [mx, settings, syncEnabled, setSyncStatus]); } diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx index d1192e1b80..1637a4e53c 100644 --- a/src/app/pages/ThemeManager.test.tsx +++ b/src/app/pages/ThemeManager.test.tsx @@ -10,6 +10,12 @@ const settings = { underlineLinks: false, reducedMotion: false, themeRemoteEnabledTweakFullUrls: [] as string[], + themeRemoteTweakFavorites: [] as { + fullUrl: string; + displayName: string; + basename: string; + cssText?: string; + }[], }; let systemThemeKind = ThemeKind.Light; @@ -87,6 +93,7 @@ beforeEach(() => { settings.underlineLinks = false; settings.reducedMotion = false; settings.themeRemoteEnabledTweakFullUrls = []; + settings.themeRemoteTweakFavorites = []; cachedCss = ''; cacheUpdateListener = undefined; document.body.className = ''; @@ -150,4 +157,27 @@ describe('ThemeManager', () => { expect(document.getElementById('sable-remote-theme-style')).toHaveTextContent('blue') ); }); + + it('applies embedded CSS for a restored local tweak when the cache is unavailable', async () => { + const tweakUrl = 'sable-import://tweak/restored/full.sable.css'; + settings.themeRemoteEnabledTweakFullUrls = [tweakUrl]; + settings.themeRemoteTweakFavorites = [ + { + fullUrl: tweakUrl, + displayName: 'Restored', + basename: 'restored', + cssText: '.restored { color: red; }', + }, + ]; + + render( + +
child
+
+ ); + + await waitFor(() => + expect(document.getElementById('sable-remote-tweaks-style')).toHaveTextContent('color: red') + ); + }); }); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index ecafc6820d..ddd74e7eb7 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -16,6 +16,7 @@ import { useOptionalClientConfig } from '$hooks/useClientConfig'; import { getCachedThemeCss, putCachedThemeCss, subscribeThemeCacheUpdates } from '../theme/cache'; import { applyCspNonce } from '$utils/cspNonce'; import { isLocalImportBundledUrl } from '../theme/localImportUrls'; +import { getEmbeddedLocalTweakCss } from '../theme/syncedTweakCss'; import { themeCatalogListingBaseUrl } from '../theme/catalogDefaults'; import { fetch } from '$utils/fetch'; import { @@ -138,6 +139,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const [underlineLinks] = useSetting(settingsAtom, 'underlineLinks'); const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); const [enabledTweakUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); useEffect(() => { document.body.className = ''; @@ -213,7 +215,15 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } const applyTweakCss = async () => { - const texts = await Promise.all(urls.map((url) => loadRemoteThemeCssText(url.trim()))); + const texts = await Promise.all( + urls.map((url) => { + const trimmedUrl = url.trim(); + const embeddedCss = getEmbeddedLocalTweakCss( + tweakFavorites?.find((favorite) => favorite.fullUrl === trimmedUrl) + ); + return embeddedCss ?? loadRemoteThemeCssText(trimmedUrl); + }) + ); if (cancelled) return; const chunks = texts.filter((text): text is string => Boolean(text)); let node = document.getElementById(REMOTE_TWEAKS_STYLE_ID) as HTMLStyleElement | null; @@ -235,7 +245,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { cancelled = true; unsubscribe(); }; - }, [enabledTweakUrls]); + }, [enabledTweakUrls, tweakFavorites]); return ( <> diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 44a4aa964b..8954a00816 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -7,6 +7,7 @@ import type { PushTransportOverrides, } from '$features/settings/notifications/NotificationTransport'; import type { IImageInfo } from '$types/matrix/common'; +import { isLocalImportTweakUrl } from '../theme/localImportUrls'; import { sanitizeShortcutOverrides, type ShortcutOverrides } from '../keyboard/shortcuts'; const STORAGE_KEY = 'settings'; @@ -66,6 +67,8 @@ export type ThemeRemoteTweakFavorite = { basename: string; pinned?: boolean; importedLocal?: boolean; + /** CSS for locally imported tweaks, replicated with settings for device restore. */ + cssText?: string; }; /** Custom profile card hero colors: which brightness schemes to honor. */ @@ -589,7 +592,9 @@ function sanitizeThemeRemoteFavorites(val: unknown): ThemeRemoteFavorite[] | und return out; } -function sanitizeThemeRemoteTweakFavorites(val: unknown): ThemeRemoteTweakFavorite[] | undefined { +export function sanitizeThemeRemoteTweakFavorites( + val: unknown +): ThemeRemoteTweakFavorite[] | undefined { if (!Array.isArray(val)) return undefined; const out: ThemeRemoteTweakFavorite[] = []; for (const item of val) { @@ -600,12 +605,15 @@ function sanitizeThemeRemoteTweakFavorites(val: unknown): ThemeRemoteTweakFavori typeof o.displayName === 'string' && typeof o.basename === 'string' ) { + const cssText = + isLocalImportTweakUrl(o.fullUrl) && typeof o.cssText === 'string' ? o.cssText : undefined; out.push({ fullUrl: o.fullUrl, displayName: o.displayName, basename: o.basename, pinned: typeof o.pinned === 'boolean' ? o.pinned : undefined, importedLocal: typeof o.importedLocal === 'boolean' ? o.importedLocal : undefined, + cssText, }); } } diff --git a/src/app/theme/processThemeImport.test.ts b/src/app/theme/processThemeImport.test.ts new file mode 100644 index 0000000000..2bd9989b16 --- /dev/null +++ b/src/app/theme/processThemeImport.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./cache', () => ({ + putCachedThemeCss: vi + .fn<(url: string, cssText: string) => Promise>() + .mockResolvedValue(undefined), +})); + +import { processPastedOrUploadedCss } from './processThemeImport'; + +describe('processPastedOrUploadedCss', () => { + it('returns CSS with a locally pasted tweak for settings sync', async () => { + const cssText = '/*\n@sable-tweak\nname: Pasted\n*/\n.pasted { color: red; }'; + const result = await processPastedOrUploadedCss(cssText); + + expect(result).toMatchObject({ + ok: true, + role: 'tweak', + importedLocal: true, + cssText, + }); + }); +}); diff --git a/src/app/theme/processThemeImport.ts b/src/app/theme/processThemeImport.ts index 3dab60aa4a..bcc9e5be16 100644 --- a/src/app/theme/processThemeImport.ts +++ b/src/app/theme/processThemeImport.ts @@ -35,6 +35,8 @@ export type ProcessedThemeImport = basename: string; description?: string; importedLocal: boolean; + /** Present for pasted/uploaded local tweaks so callers can persist it with the favorite. */ + cssText?: string; } | { ok: false; error: string }; @@ -268,6 +270,7 @@ export async function processPastedOrUploadedCss( basename, description: tweakMeta.description?.trim(), importedLocal: true, + cssText: trimmed, }; } diff --git a/src/app/theme/syncedTweakCss.test.ts b/src/app/theme/syncedTweakCss.test.ts new file mode 100644 index 0000000000..220031acb6 --- /dev/null +++ b/src/app/theme/syncedTweakCss.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { getCachedThemeCss, putCachedThemeCss } = vi.hoisted(() => ({ + getCachedThemeCss: vi + .fn<(url: string) => Promise>() + .mockResolvedValue(undefined), + putCachedThemeCss: vi + .fn<(url: string, cssText: string) => Promise>() + .mockResolvedValue(undefined), +})); + +vi.mock('./cache', () => ({ getCachedThemeCss, putCachedThemeCss })); + +import { + getEmbeddedLocalTweakCss, + hydrateSyncedLocalTweakCss, + resolveSavedTweakCss, +} from './syncedTweakCss'; + +describe('synced local tweak CSS', () => { + it('hydrates only embedded local tweak CSS and tolerates cache failures', async () => { + putCachedThemeCss.mockRejectedValueOnce(new Error('IndexedDB unavailable')); + await expect( + hydrateSyncedLocalTweakCss([ + { + fullUrl: 'sable-import://tweak/one/full.sable.css', + displayName: 'One', + basename: 'one', + cssText: 'a {}', + }, + { + fullUrl: 'https://example.test/tweak.sable.css', + displayName: 'Remote', + basename: 'remote', + cssText: 'b {}', + }, + ]) + ).resolves.toBeUndefined(); + expect(putCachedThemeCss).toHaveBeenCalledTimes(1); + }); + + it('provides embedded CSS as a deterministic local-listing fallback only', () => { + expect( + getEmbeddedLocalTweakCss({ + fullUrl: 'sable-import://tweak/one/full.sable.css', + displayName: 'One', + basename: 'one', + cssText: 'a {}', + }) + ).toBe('a {}'); + expect( + getEmbeddedLocalTweakCss({ + fullUrl: 'https://example.test/tweak.sable.css', + displayName: 'Remote', + basename: 'remote', + cssText: 'b {}', + }) + ).toBeUndefined(); + }); + + it('resolves embedded local CSS for saved-tweak rendering when cache reads and writes fail', async () => { + const favorite = { + fullUrl: 'sable-import://tweak/one/full.sable.css', + displayName: 'One', + basename: 'one', + cssText: 'a {}', + }; + getCachedThemeCss.mockRejectedValueOnce(new Error('IndexedDB unavailable')); + putCachedThemeCss.mockRejectedValueOnce(new Error('IndexedDB unavailable')); + + await expect(resolveSavedTweakCss(favorite)).resolves.toBe('a {}'); + }); + + it('prefers newer embedded local CSS over stale cached CSS', async () => { + getCachedThemeCss.mockResolvedValueOnce('stale {}'); + const callsBefore = getCachedThemeCss.mock.calls.length; + await expect( + resolveSavedTweakCss({ + fullUrl: 'sable-import://tweak/one/full.sable.css', + displayName: 'One', + basename: 'one', + cssText: 'new {}', + }) + ).resolves.toBe('new {}'); + expect(getCachedThemeCss).toHaveBeenCalledTimes(callsBefore); + }); +}); diff --git a/src/app/theme/syncedTweakCss.ts b/src/app/theme/syncedTweakCss.ts new file mode 100644 index 0000000000..6e37104128 --- /dev/null +++ b/src/app/theme/syncedTweakCss.ts @@ -0,0 +1,65 @@ +import type { ThemeRemoteTweakFavorite } from '$state/settings'; + +import { getCachedThemeCss, putCachedThemeCss } from './cache'; +import { isLocalImportTweakUrl } from './localImportUrls'; + +/** + * Restores replicated local tweak CSS to the device-local cache. Cache failures + * are deliberately ignored: settings sync must work without IndexedDB. + */ +export async function hydrateSyncedLocalTweakCss( + favorites: ThemeRemoteTweakFavorite[] +): Promise { + await Promise.all( + favorites.map(async ({ fullUrl, cssText }) => { + if (!isLocalImportTweakUrl(fullUrl) || typeof cssText !== 'string') return; + try { + await putCachedThemeCss(fullUrl, cssText); + } catch { + // IndexedDB may be unavailable (for example in private browsing). + } + }) + ); +} + +/** CSS embedded in settings is only valid for local tweak imports. */ +export function getEmbeddedLocalTweakCss( + favorite: ThemeRemoteTweakFavorite | undefined +): string | undefined { + return favorite && isLocalImportTweakUrl(favorite.fullUrl) ? favorite.cssText : undefined; +} + +/** Resolve saved tweak CSS from cache, with embedded local CSS as a cache-independent fallback. */ +export async function resolveSavedTweakCss(favorite: ThemeRemoteTweakFavorite): Promise { + const embeddedCss = getEmbeddedLocalTweakCss(favorite); + if (embeddedCss) { + void putCachedThemeCss(favorite.fullUrl, embeddedCss).catch(() => undefined); + return embeddedCss; + } + try { + const cachedCss = await getCachedThemeCss(favorite.fullUrl); + if (cachedCss) return cachedCss; + } catch { + // Embedded CSS remains usable when IndexedDB is unavailable. + } + return ''; +} + +/** Restores CSS for pre-sync local favorites from the device cache before upload. */ +export async function backfillLocalTweakCss( + favorites: ThemeRemoteTweakFavorite[] +): Promise { + return Promise.all( + favorites.map(async (favorite) => { + if (!isLocalImportTweakUrl(favorite.fullUrl) || typeof favorite.cssText === 'string') { + return favorite; + } + try { + const cssText = await getCachedThemeCss(favorite.fullUrl); + return typeof cssText === 'string' ? { ...favorite, cssText } : favorite; + } catch { + return favorite; + } + }) + ); +} diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 585a8fb9f5..040fbcd75c 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -7,6 +7,7 @@ import { deserializeFromSync, exportSettingsAsJson, importSettingsFromJson, + prepareSettingsForSync, } from './settingsSync'; // fixtures @@ -183,6 +184,191 @@ describe('deserializeFromSync', () => { expect(result!.settingsSyncEnabled).toBe(base.settingsSyncEnabled); }); + it('round-trips embedded local tweak CSS without changing the sync version', () => { + const tweakUrl = 'sable-import://tweak/restored/full.sable.css'; + const tweaked = { + ...base, + themeRemoteTweakFavorites: [ + { fullUrl: tweakUrl, displayName: 'Restored', basename: 'restored', cssText: 'body {}' }, + ], + }; + const payload = serializeForSync(tweaked); + expect(payload.v).toBe(SETTINGS_SYNC_VERSION); + expect(payload.settings.themeRemoteTweakFavorites?.[0]?.cssText).toBe('body {}'); + expect(deserializeFromSync(payload, base)?.themeRemoteTweakFavorites[0]?.cssText).toBe( + 'body {}' + ); + }); + + it('atomically excludes local tweaks beyond the aggregate CSS budget and their enabled URLs', () => { + const firstUrl = 'sable-import://tweak/first/full.sable.css'; + const secondUrl = 'sable-import://tweak/second/full.sable.css'; + const prepared = prepareSettingsForSync({ + ...base, + themeRemoteTweakFavorites: [ + { + fullUrl: firstUrl, + displayName: 'First', + basename: 'first', + cssText: 'a'.repeat(256 * 1024), + }, + { fullUrl: secondUrl, displayName: 'Second', basename: 'second', cssText: 'b' }, + ], + themeRemoteEnabledTweakFullUrls: [firstUrl, secondUrl], + }); + expect(prepared.excludedLocalTweakUrls).toEqual([secondUrl]); + expect( + prepared.content.settings.themeRemoteTweakFavorites?.map((favorite) => favorite.fullUrl) + ).toEqual([firstUrl]); + expect(prepared.content.settings.themeRemoteEnabledTweakFullUrls).toEqual([firstUrl]); + }); + + it('preserves locally excluded oversized tweaks and enabled URLs during a remote merge', () => { + const oversizedUrl = 'sable-import://tweak/oversized/full.sable.css'; + const current = { + ...base, + themeRemoteTweakFavorites: [ + { + fullUrl: oversizedUrl, + displayName: 'Oversized', + basename: 'oversized', + cssText: 'x'.repeat(256 * 1024 + 1), + }, + ], + themeRemoteEnabledTweakFullUrls: [oversizedUrl], + }; + const result = deserializeFromSync( + { + v: SETTINGS_SYNC_VERSION, + settings: { + themeRemoteTweakFavorites: [ + { + fullUrl: 'https://example.test/remote.css', + displayName: 'Remote', + basename: 'remote', + }, + ], + themeRemoteEnabledTweakFullUrls: ['https://example.test/remote.css'], + }, + }, + current + ); + expect(result?.themeRemoteTweakFavorites.map((favorite) => favorite.fullUrl)).toEqual([ + 'https://example.test/remote.css', + oversizedUrl, + ]); + expect(result?.themeRemoteEnabledTweakFullUrls).toEqual([ + 'https://example.test/remote.css', + oversizedUrl, + ]); + + const enabledOnlyResult = deserializeFromSync( + { + v: SETTINGS_SYNC_VERSION, + settings: { themeRemoteEnabledTweakFullUrls: ['https://example.test/remote.css'] }, + }, + current + ); + expect(enabledOnlyResult?.themeRemoteEnabledTweakFullUrls).toEqual([ + 'https://example.test/remote.css', + oversizedUrl, + ]); + }); + + it('drops embedded CSS for remote tweak URLs without dropping local CSS', () => { + const remote = { + v: SETTINGS_SYNC_VERSION, + settings: { + themeRemoteTweakFavorites: [ + { + fullUrl: 'https://example.test/tweak.sable.css', + displayName: 'Remote', + basename: 'remote', + cssText: 'body {}', + }, + { + fullUrl: 'sable-import://tweak/local/full.sable.css', + displayName: 'Local', + basename: 'local', + cssText: 'body {}', + }, + ], + }, + }; + expect(deserializeFromSync(remote, base)?.themeRemoteTweakFavorites).toEqual([ + { + fullUrl: 'https://example.test/tweak.sable.css', + displayName: 'Remote', + basename: 'remote', + }, + { + fullUrl: 'sable-import://tweak/local/full.sable.css', + displayName: 'Local', + basename: 'local', + cssText: 'body {}', + }, + ]); + }); + + it('preserves body-less legacy local records for a source device to backfill later', () => { + const tweakUrl = 'sable-import://tweak/legacy/full.sable.css'; + const remote = { + v: SETTINGS_SYNC_VERSION, + settings: { + themeRemoteTweakFavorites: [ + { fullUrl: tweakUrl, displayName: 'Legacy', basename: 'legacy' }, + ], + themeRemoteEnabledTweakFullUrls: [tweakUrl], + }, + }; + expect( + deserializeFromSync(remote, { + ...base, + themeRemoteTweakFavorites: [{ fullUrl: tweakUrl, displayName: 'Local', basename: 'local' }], + })?.themeRemoteTweakFavorites + ).toHaveLength(1); + const restored = deserializeFromSync(remote, base); + expect(restored?.themeRemoteTweakFavorites).toHaveLength(1); + expect(restored?.themeRemoteEnabledTweakFullUrls).toEqual([tweakUrl]); + }); + + it('preserves structured v1 settings during deserialization', () => { + const result = deserializeFromSync( + { + v: SETTINGS_SYNC_VERSION, + settings: { perRoomShowRoomIcon: { '!room:example': 'always' } }, + }, + base + ); + expect(result?.perRoomShowRoomIcon).toEqual({ '!room:example': 'always' }); + }); + + it('falls back safely for malformed tweak fields and sanitizes enabled URLs', () => { + const current = { + ...base, + themeRemoteTweakFavorites: [ + { fullUrl: 'https://example.test/ok.css', displayName: 'Ok', basename: 'ok' }, + ], + }; + const result = deserializeFromSync( + { + v: SETTINGS_SYNC_VERSION, + settings: { + themeRemoteTweakFavorites: { bad: true }, + themeRemoteEnabledTweakFullUrls: [ + ' https://example.test/ok.css ', + 4, + '', + 'x'.repeat(8193), + ], + }, + }, + current + ); + expect(result?.themeRemoteTweakFavorites).toEqual(current.themeRemoteTweakFavorites); + expect(result?.themeRemoteEnabledTweakFullUrls).toEqual(['https://example.test/ok.css']); + }); + it('ignores extra unknown keys in the remote payload', () => { const remote = { v: SETTINGS_SYNC_VERSION, diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index e37af34b65..b7231ac6a9 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -1,4 +1,5 @@ -import type { Settings } from '$state/settings'; +import { sanitizeThemeRemoteTweakFavorites, type Settings } from '$state/settings'; +import { isLocalImportTweakUrl } from '../theme/localImportUrls'; import { sanitizeShortcutOverrides } from '../keyboard/shortcuts'; /** @@ -31,19 +32,57 @@ export const NON_SYNCABLE_KEYS = new Set([ ]); export const SETTINGS_SYNC_VERSION = 1; +export const MAX_SYNCED_LOCAL_TWEAK_CSS_BYTES = 256 * 1024; +const MAX_SYNCED_TWEAK_URL_LENGTH = 8192; export type SettingsSyncContent = { v: number; settings: Partial; }; -/** Strip non-syncable keys and wrap in a versioned envelope. */ -export const serializeForSync = (settings: Settings): SettingsSyncContent => { +export type PreparedSettingsSync = { + content: SettingsSyncContent; + excludedLocalTweakUrls: string[]; +}; + +export function sanitizeThemeRemoteEnabledTweakFullUrls(val: unknown): string[] | undefined { + if (!Array.isArray(val)) return undefined; + return val.flatMap((url) => { + if (typeof url !== 'string') return []; + const trimmed = url.trim(); + return trimmed.length > 0 && trimmed.length <= MAX_SYNCED_TWEAK_URL_LENGTH ? [trimmed] : []; + }); +} + +/** Prepare a bounded, atomic local-tweak payload and report any excluded URLs. */ +export const prepareSettingsForSync = (settings: Settings): PreparedSettingsSync => { const syncable = { ...settings } as Partial; + const favorites = sanitizeThemeRemoteTweakFavorites(settings.themeRemoteTweakFavorites) ?? []; + let usedCssBytes = 0; + const excludedLocalTweakUrls: string[] = []; + syncable.themeRemoteTweakFavorites = favorites.filter((favorite) => { + if (!isLocalImportTweakUrl(favorite.fullUrl) || typeof favorite.cssText !== 'string') + return true; + const bytes = new TextEncoder().encode(favorite.cssText).byteLength; + if (usedCssBytes + bytes > MAX_SYNCED_LOCAL_TWEAK_CSS_BYTES) { + excludedLocalTweakUrls.push(favorite.fullUrl); + return false; + } + usedCssBytes += bytes; + return true; + }); + const excluded = new Set(excludedLocalTweakUrls); + syncable.themeRemoteEnabledTweakFullUrls = ( + sanitizeThemeRemoteEnabledTweakFullUrls(settings.themeRemoteEnabledTweakFullUrls) ?? [] + ).filter((url) => !excluded.has(url)); NON_SYNCABLE_KEYS.forEach((key) => delete syncable[key]); - return { v: SETTINGS_SYNC_VERSION, settings: syncable }; + return { content: { v: SETTINGS_SYNC_VERSION, settings: syncable }, excludedLocalTweakUrls }; }; +/** Strip non-syncable keys and wrap in a versioned envelope. */ +export const serializeForSync = (settings: Settings): SettingsSyncContent => + prepareSettingsForSync(settings).content; + /** * Validate incoming account data and merge it into current settings. * Returns null when the data is invalid or from an incompatible schema version. @@ -56,9 +95,50 @@ export const deserializeFromSync = (data: unknown, currentSettings: Settings): S const remote = content.settings; if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return null; - const merged = { ...currentSettings, ...(remote as Partial) }; + const remoteSettings = remote as Partial; + const merged = { ...currentSettings, ...remoteSettings }; + const remoteTweakFavorites = sanitizeThemeRemoteTweakFavorites( + remoteSettings.themeRemoteTweakFavorites + ); + if ('themeRemoteTweakFavorites' in remoteSettings) { + const excluded = new Set(prepareSettingsForSync(currentSettings).excludedLocalTweakUrls); + const remoteFavorites = remoteTweakFavorites ?? currentSettings.themeRemoteTweakFavorites; + const remoteUrls = new Set(remoteFavorites.map((favorite) => favorite.fullUrl)); + const preservedExcluded = currentSettings.themeRemoteTweakFavorites.filter( + (favorite) => excluded.has(favorite.fullUrl) && !remoteUrls.has(favorite.fullUrl) + ); + merged.themeRemoteTweakFavorites = [...remoteFavorites, ...preservedExcluded]; + if ('themeRemoteEnabledTweakFullUrls' in remoteSettings) { + const remoteEnabled = + sanitizeThemeRemoteEnabledTweakFullUrls(remoteSettings.themeRemoteEnabledTweakFullUrls) ?? + currentSettings.themeRemoteEnabledTweakFullUrls; + const remoteEnabledSet = new Set(remoteEnabled); + merged.themeRemoteEnabledTweakFullUrls = [ + ...remoteEnabled, + ...currentSettings.themeRemoteEnabledTweakFullUrls.filter( + (url) => excluded.has(url) && !remoteEnabledSet.has(url) + ), + ]; + } + } + if ( + 'themeRemoteEnabledTweakFullUrls' in remoteSettings && + !('themeRemoteTweakFavorites' in remoteSettings) + ) { + const remoteEnabled = + sanitizeThemeRemoteEnabledTweakFullUrls(remoteSettings.themeRemoteEnabledTweakFullUrls) ?? + currentSettings.themeRemoteEnabledTweakFullUrls; + const remoteEnabledSet = new Set(remoteEnabled); + const excluded = new Set(prepareSettingsForSync(currentSettings).excludedLocalTweakUrls); + merged.themeRemoteEnabledTweakFullUrls = [ + ...remoteEnabled, + ...currentSettings.themeRemoteEnabledTweakFullUrls.filter( + (url) => excluded.has(url) && !remoteEnabledSet.has(url) + ), + ]; + } merged.shortcutOverrides = - sanitizeShortcutOverrides((remote as Partial).shortcutOverrides) ?? + sanitizeShortcutOverrides(remoteSettings.shortcutOverrides) ?? currentSettings.shortcutOverrides; // Always restore non-syncable keys from local state. NON_SYNCABLE_KEYS.forEach((key) => { From b01e9529d0496b6fe243edd01c390671f4b1474d Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 20:24:45 +0200 Subject: [PATCH 2/3] docs: document synced tweak fix --- .changeset/fix-synced-local-tweaks.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-synced-local-tweaks.md diff --git a/.changeset/fix-synced-local-tweaks.md b/.changeset/fix-synced-local-tweaks.md new file mode 100644 index 0000000000..6c396b71f0 --- /dev/null +++ b/.changeset/fix-synced-local-tweaks.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Restore locally imported CSS tweaks when syncing settings between devices. From 75dcbf5a28a2837ddff0c2a40c27e2d2d43bc9b3 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 20:44:15 +0200 Subject: [PATCH 3/3] fix: satisfy lint checks --- .../settings/cosmetics/ThemeCatalogSettings.test.tsx | 9 +++++++-- src/app/hooks/useSettingsSync.test.tsx | 12 ++++++------ src/app/pages/ThemeManager.tsx | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx index e428bf9bf2..9e4d502891 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx @@ -37,7 +37,10 @@ const settings = { }; vi.mock('$state/hooks/settings', () => ({ - useSetting: (_atom: unknown, key: keyof typeof settings) => [settings[key], vi.fn()], + useSetting: (_atom: unknown, key: keyof typeof settings) => [ + settings[key], + vi.fn<(value: unknown) => void>(), + ], })); vi.mock('$state/settings', () => ({ settingsAtom: {}, @@ -49,7 +52,9 @@ vi.mock('$state/settings', () => ({ }), })); vi.mock('$hooks/useClientConfig', () => ({ useClientConfig: () => ({}) })); -vi.mock('$utils/share', () => ({ shareText: vi.fn() })); +vi.mock('$utils/share', () => ({ + shareText: vi.fn<(text: string) => Promise>().mockResolvedValue(false), +})); vi.mock('../../../theme/cache', () => ({ getCachedThemeCss, putCachedThemeCss, diff --git a/src/app/hooks/useSettingsSync.test.tsx b/src/app/hooks/useSettingsSync.test.tsx index 1074290a61..1bb69473c9 100644 --- a/src/app/hooks/useSettingsSync.test.tsx +++ b/src/app/hooks/useSettingsSync.test.tsx @@ -275,7 +275,7 @@ describe('useSettingsSyncEffect — debounced upload', () => { }); it('cancels a stale deferred backfill when settings change', async () => { - let resolveCache: (css: string | undefined) => void = () => undefined; + let resolveCache: ((css: string | undefined) => void) | undefined; getCachedThemeCss.mockImplementationOnce( () => new Promise((resolve) => (resolveCache = resolve)) ); @@ -292,13 +292,13 @@ describe('useSettingsSyncEffect — debounced upload', () => { renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); act(() => vi.advanceTimersByTime(2000)); act(() => store.set(settingsAtom, { ...store.get(settingsAtom), twitterEmoji: false })); - resolveCache('body {}'); + resolveCache?.('body {}'); await act(async () => await Promise.resolve()); expect(mockMx.setAccountData).not.toHaveBeenCalled(); }); it('cancels a deferred backfill when settings sync is disabled', async () => { - let resolveCache: (css: string | undefined) => void = () => undefined; + let resolveCache: ((css: string | undefined) => void) | undefined; getCachedThemeCss.mockImplementationOnce( () => new Promise((resolve) => (resolveCache = resolve)) ); @@ -315,7 +315,7 @@ describe('useSettingsSyncEffect — debounced upload', () => { renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); act(() => vi.advanceTimersByTime(2000)); act(() => store.set(settingsAtom, { ...store.get(settingsAtom), settingsSyncEnabled: false })); - resolveCache('body {}'); + resolveCache?.('body {}'); await act(async () => await Promise.resolve()); expect(mockMx.setAccountData).not.toHaveBeenCalled(); }); @@ -347,7 +347,7 @@ describe('useSettingsSyncEffect — debounced upload', () => { }); it('keeps idle state after disabling sync during an in-flight request and ignores its late failure', async () => { - let rejectUpload: (error: Error) => void = () => undefined; + let rejectUpload: ((error: Error) => void) | undefined; mockMx.setAccountData.mockImplementationOnce( () => new Promise((_resolve, reject) => (rejectUpload = reject)) ); @@ -360,7 +360,7 @@ describe('useSettingsSyncEffect — debounced upload', () => { expect(store.get(settingsSyncStatusAtom)).toBe('syncing'); act(() => store.set(settingsAtom, { ...store.get(settingsAtom), settingsSyncEnabled: false })); expect(store.get(settingsSyncStatusAtom)).toBe('idle'); - rejectUpload(new Error('late failure')); + rejectUpload?.(new Error('late failure')); await act(async () => await Promise.resolve()); expect(store.get(settingsSyncStatusAtom)).toBe('idle'); expect(store.get(settingsAtom).twitterEmoji).toBe(true); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index ddd74e7eb7..730d356584 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -216,7 +216,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const applyTweakCss = async () => { const texts = await Promise.all( - urls.map((url) => { + urls.map(async (url) => { const trimmedUrl = url.trim(); const embeddedCss = getEmbeddedLocalTweakCss( tweakFavorites?.find((favorite) => favorite.fullUrl === trimmedUrl)