From 7b3a4145a7d7c98c0a7fc84c18d0d43c3735826f Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 22 May 2026 01:18:15 +0300 Subject: [PATCH] fix(channels): back active-workspace persistence with a jotai atom so the native pager sees switcher picks instead of a stale memoized localStorage read --- src/app/pages/client/channels/Channels.tsx | 20 +++++----- .../pages/client/channels/useActiveSpace.ts | 32 ++++++++-------- src/app/state/activeChannelsSpace.ts | 37 +++++++++++++++++++ 3 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 src/app/state/activeChannelsSpace.ts diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx index 45db0c72..429703d7 100644 --- a/src/app/pages/client/channels/Channels.tsx +++ b/src/app/pages/client/channels/Channels.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSetAtom } from 'jotai'; import { Icons } from 'folds'; import { useSpace } from '../../../hooks/useSpace'; import { PageNav, PageNavContent } from '../../../components/page'; @@ -8,12 +9,12 @@ import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/Mobile import { StreamHeaderPrimaryAction } from '../../../state/mobilePagerHeader'; import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal'; import { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal'; +import { activeChannelsSpaceAtom } from '../../../state/activeChannelsSpace'; import { CreateRoomType } from '../../../components/create-room/types'; import { ChannelsList } from './ChannelsList'; import { ChannelsLanding } from './ChannelsLanding'; import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { WorkspaceFooter } from './WorkspaceFooter'; -import { ACTIVE_SPACE_KEY } from './useActiveSpace'; // Index route at /channels/ (no space selected). Renders the shared // StreamHeader (segment switcher) plus the resolve-active-space-or- @@ -79,18 +80,17 @@ export function Channels() { const scrollRef = useRef(null); const inPagerMode = useMobilePagerPane() !== null; const openCreateRoomModal = useOpenCreateRoomModal(); + const setActiveSpace = useSetAtom(activeChannelsSpaceAtom); // Persist URL-driven active space so cold-starts at /channels/ resume on - // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the - // value but never writes it because the index route has no - // :spaceIdOrAlias param — the write happens here. + // the same workspace. `useActiveSpace` (in ChannelsLanding and + // MobileTabsPager) subscribes to the same atom, so this write + // immediately invalidates their cached reads — without that, the pager + // would serve a stale `destinationFor('channels')` after a switcher + // pick + tab swap on native. useEffect(() => { - try { - localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId); - } catch { - /* private mode / quota — non-fatal */ - } - }, [space.roomId]); + setActiveSpace(space.roomId); + }, [setActiveSpace, space.roomId]); // Plus on the tabs row creates a channel inside the active workspace. // Single entry point for «create» on the Channels tab. diff --git a/src/app/pages/client/channels/useActiveSpace.ts b/src/app/pages/client/channels/useActiveSpace.ts index 2b0bacf6..985a519e 100644 --- a/src/app/pages/client/channels/useActiveSpace.ts +++ b/src/app/pages/client/channels/useActiveSpace.ts @@ -1,18 +1,10 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { activeChannelsSpaceAtom } from '../../../state/activeChannelsSpace'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../../utils/matrix'; -export const ACTIVE_SPACE_KEY = 'vojo.activeSpaceId'; - -const readPersisted = (): string | null => { - try { - return localStorage.getItem(ACTIVE_SPACE_KEY); - } catch { - return null; - } -}; - const safeDecode = (raw: string): string | undefined => { try { return decodeURIComponent(raw); @@ -23,16 +15,22 @@ const safeDecode = (raw: string): string | undefined => { // Resolves the active Space for the channels segment. Priority: // 1. URL `:spaceIdOrAlias` param (if joined-orphan) -// 2. localStorage['vojo.activeSpaceId'] (if joined-orphan) +// 2. `activeChannelsSpaceAtom` (= localStorage, reactive) (if joined-orphan) // 3. first joined-orphan Space // Returns undefined when the user has 0 joined orphan spaces. Persistence -// (writing to localStorage) lives in `Channels.tsx`, where the inner -// route already has the resolved space context — at the index `/channels/` +// (writing the atom) lives in `Channels.tsx`, where the inner route +// already has the resolved space context — at the index `/channels/` // route, useParams().spaceIdOrAlias is always undefined. +// +// The persisted value is read via Jotai so writes from `Channels.tsx` +// immediately invalidate every consumer (notably `MobileTabsPager`, +// which stays mounted across tab swipes and would otherwise serve a +// stale cached value to its `destinationFor('channels')`). export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined => { const mx = useMatrixClient(); const { spaceIdOrAlias } = useParams(); const orphanSet = useMemo(() => new Set(orphanSpaceIds), [orphanSpaceIds]); + const persisted = useAtomValue(activeChannelsSpaceAtom); const urlSpaceId = useMemo(() => { if (!spaceIdOrAlias) return undefined; @@ -42,10 +40,10 @@ export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined => return resolved && orphanSet.has(resolved) ? resolved : undefined; }, [mx, spaceIdOrAlias, orphanSet]); - const persistedSpaceId = useMemo(() => { - const stored = readPersisted(); - return stored && orphanSet.has(stored) ? stored : undefined; - }, [orphanSet]); + const persistedSpaceId = useMemo( + () => (persisted && orphanSet.has(persisted) ? persisted : undefined), + [persisted, orphanSet] + ); return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0]; }; diff --git a/src/app/state/activeChannelsSpace.ts b/src/app/state/activeChannelsSpace.ts new file mode 100644 index 00000000..8d04b8b5 --- /dev/null +++ b/src/app/state/activeChannelsSpace.ts @@ -0,0 +1,37 @@ +import { atomWithLocalStorage } from './utils/atomWithLocalStorage'; + +// Persisted across reloads and tabs as a plain string (not JSON-encoded) +// to stay backwards-compatible with the legacy `localStorage.setItem(key, roomId)` +// writes that shipped before this atom existed. +export const ACTIVE_CHANNELS_SPACE_KEY = 'vojo.activeSpaceId'; + +const getRawString = (key: string): string | undefined => { + try { + return localStorage.getItem(key) ?? undefined; + } catch { + return undefined; + } +}; + +const setRawString = (key: string, value: string | undefined) => { + try { + if (value === undefined) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, value); + } + } catch { + /* private mode / quota — non-fatal */ + } +}; + +// Single source of truth for the channels-tab «active workspace» selection. +// Subscribed reads (via `useAtomValue`) re-render when the atom is written +// in the same tab and when another tab updates localStorage — fixes the +// stale-memo bug where `MobileTabsPager` cached the persisted value at +// first render and never refreshed after the user picked a new workspace. +export const activeChannelsSpaceAtom = atomWithLocalStorage( + ACTIVE_CHANNELS_SPACE_KEY, + getRawString, + setRawString +);