From 795a8b9a8354a582bae42b79ff39ed71cb9661ab Mon Sep 17 00:00:00 2001 From: Arvin Date: Thu, 10 Sep 2026 19:35:11 +0200 Subject: [PATCH] fix(web): preserve device profile selection during cloud hydration --- web/lib/profiles.ts | 7 +++++++ web/lib/store.tsx | 18 +++++++++--------- web/tests/tv-polish.test.cjs | 10 ++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/web/lib/profiles.ts b/web/lib/profiles.ts index bfe7e68b7..bc02d7b73 100644 --- a/web/lib/profiles.ts +++ b/web/lib/profiles.ts @@ -1,5 +1,12 @@ import type { Profile } from "./types"; +/** Cloud shares profiles, not which person is currently using this device. */ +export function hydratedProfileId(localId: string | null, profiles: Pick[], cloudId?: string | null): string | null { + if (localId && profiles.some(profile => profile.id === localId)) return localId; + if (cloudId && profiles.some(profile => profile.id === cloudId)) return cloudId; + return profiles[0]?.id ?? null; +} + /** Netflix-style profile colors — mirrors Android ProfileColors.colors (ARGB longs). */ export const profileColors = [ 0xffe50914, // Netflix Red diff --git a/web/lib/store.tsx b/web/lib/store.tsx index a553fe1a9..51cb12e72 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -21,6 +21,7 @@ import { dedupeMedia, historyToItem, hydrateTraktItems, traktItemToMedia, traktP import { loadStored, purgeLegacyStorage, removeStored, saveStored } from "./storage"; import { getDetails, getSeasonEpisodes, loadCatalog, searchMedia, resolveTmdbId } from "./tmdb"; import { verifyProfilePin } from "./profilePin"; +import { hydratedProfileId } from "./profiles"; import { flushSettingsOutbox, hasPendingSettings, queueSettings } from "./settingsOutbox"; import type { MetadataProviderId, ProviderPriorityConfig } from "./metadata/types"; import { TraktClient, type TraktDeviceCode } from "./trakt"; @@ -1377,13 +1378,12 @@ export function AppProvider({ if (cloud.profiles.length) { setProfiles(cloud.profiles); setAvatarImages(cloud.avatarImages); - if (cloud.activeProfileId) { - setActiveProfileId(cloud.activeProfileId); - void refreshData(cloud.activeProfileId); - } else if (cloud.profiles[0]) { - setActiveProfileId(cloud.profiles[0].id); - void refreshData(cloud.profiles[0].id); - } + // Read the current selection when the request completes: a user may + // have chosen a profile while this older cloud snapshot was loading. + const selectedId = hydratedProfileId(activeProfileIdRef.current, cloud.profiles, cloud.activeProfileId); + activeProfileIdRef.current = selectedId; + setActiveProfileId(selectedId); + void refreshData(selectedId); } else { // New account with no cloud profiles yet. If the local profiles were // stamped for a DIFFERENT account, they leaked from a previous @@ -1395,7 +1395,7 @@ export function AppProvider({ saveStored(PROFILES_OWNER_KEY, currentAccountEmail()); void refreshData(fresh[0].id); } else { - void refreshData(activeProfileId); + void refreshData(activeProfileIdRef.current); } } setCloudProfilesHydrated(true); @@ -1406,7 +1406,7 @@ export function AppProvider({ return () => { cancelled = true; }; - }, [activeProfileId, auth, refreshData]); + }, [auth, refreshData]); useEffect(() => { let current = true; diff --git a/web/tests/tv-polish.test.cjs b/web/tests/tv-polish.test.cjs index c7f0dbc95..e67de55a4 100644 --- a/web/tests/tv-polish.test.cjs +++ b/web/tests/tv-polish.test.cjs @@ -31,3 +31,13 @@ test('production build refuses masked values before starting Next', () => { assert.equal(result.status, 1); assert.match(result.stderr, /refusing production build/); }); + +test('late cloud hydration keeps the profile selected on this device', () => { + const { hydratedProfileId } = load('lib/profiles.ts'); + const profiles = [{ id: 'arvind' }, { id: 'shai' }]; + assert.equal(hydratedProfileId('arvind', profiles, 'shai'), 'arvind'); + assert.equal(hydratedProfileId('shai', profiles, 'arvind'), 'shai'); + assert.equal(hydratedProfileId('deleted', profiles, 'shai'), 'shai'); + assert.equal(hydratedProfileId(null, profiles, 'missing'), 'arvind'); + assert.equal(hydratedProfileId('deleted', [], 'missing'), null); +});