fix(mobile): Spaces tab remembers last-selected space across DM/Friends/Settings detours

Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.

MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.

Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.

Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
This commit is contained in:
Jannis Braun
2026-05-07 23:00:01 +02:00
parent f733e39b4a
commit d9c9f18b20
4 changed files with 80 additions and 12 deletions
@@ -10,7 +10,6 @@ export function MobileBottomNav() {
const mobileScreen = useUIStore((s) => s.mobileScreen);
const mobileStack = useUIStore((s) => s.mobileStack);
const setMobileTab = useUIStore((s) => s.setMobileTab);
const lastChannelPerSpace = useUIStore((s) => s.lastChannelPerSpace);
const navigate = useNavigate();
// Badge data
@@ -41,8 +40,17 @@ export function MobileBottomNav() {
setMobileTab(tab);
if (tab === 'dms') navigate('/channels/@me');
if (tab === 'spaces') {
const lastSpace = Object.keys(lastChannelPerSpace)[0];
if (lastSpace) navigate(`/channels/${lastSpace}`);
// Prefer `currentSpaceId` (the canonical "currently selected space" —
// updated by URL routing, the space strip, SpaceInviteCard joins, etc.).
// Fall back to `lastSelectedSpaceId`, the sticky memory that survives
// navigating to `/channels/@me`. AppLayout's URL effect clears
// `currentSpaceId` to null whenever the URL is `@me`; without the
// sticky memory, returning to Spaces from a DM/Friends/Settings detour
// would have nothing to anchor to and `MobileSpacesScreen`'s auto-select
// would fall back to `spaces[0]`.
const { currentSpaceId, lastSelectedSpaceId } = useSpaceStore.getState();
const target = currentSpaceId ?? lastSelectedSpaceId;
if (target) navigate(`/channels/${target}`);
else navigate('/');
}
};
@@ -29,6 +29,7 @@ export function MobileSpacesScreen() {
const channels = useSpaceStore((s) => s.channels);
const categories = useSpaceStore((s) => s.categories);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const lastSelectedSpaceId = useSpaceStore((s) => s.lastSelectedSpaceId);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
@@ -65,7 +66,14 @@ export function MobileSpacesScreen() {
const user = useAuthStore((s) => s.user);
// Local state
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(currentSpaceId);
// Initial value: prefer `currentSpaceId` if set (the user is currently in a
// space's URL), otherwise fall back to the sticky `lastSelectedSpaceId`.
// This handles the remount-after-DM-detour case: AppLayout's URL effect has
// cleared `currentSpaceId` to null on `/channels/@me`, but the user's
// intent — the most-recently-selected space — is still recorded.
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
currentSpaceId ?? lastSelectedSpaceId,
);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [showAddSheet, setShowAddSheet] = useState(false);
const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState<string | null>(null);
@@ -87,11 +95,16 @@ export function MobileSpacesScreen() {
// space (e.g. SpaceInviteCard's join handler seeding currentSpaceId before
// routing to the Spaces tab). Without this, useState(currentSpaceId) is only
// captured on mount and the strip stays on the previously-selected space.
//
// selectedSpaceId is intentionally NOT in the dep array: including it caused
// a render loop because handleSpaceSelect briefly leaves selectedSpaceId !==
// currentSpaceId until the existing effect below propagates the local pick
// to the store. Functional setState makes the update idempotent — if the
// store already matches, React bails and skips the re-render.
useEffect(() => {
if (currentSpaceId && currentSpaceId !== selectedSpaceId) {
setSelectedSpaceId(currentSpaceId);
}
}, [currentSpaceId, selectedSpaceId]);
if (!currentSpaceId) return;
setSelectedSpaceId((prev) => (currentSpaceId !== prev ? currentSpaceId : prev));
}, [currentSpaceId]);
// Auto-select first space if none selected
useEffect(() => {