fix(channels): back active-workspace persistence with a jotai atom so the native pager sees switcher picks instead of a stale memoized localStorage read

This commit is contained in:
heaven 2026-05-22 01:18:15 +03:00
parent 765445c091
commit 7b3a4145a7
3 changed files with 62 additions and 27 deletions

View file

@ -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<HTMLDivElement>(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.

View file

@ -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];
};

View file

@ -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<string | undefined>(
ACTIVE_CHANNELS_SPACE_KEY,
getRawString,
setRawString
);