From af008b21533bdc50fae5ad6a438347ad84bb9419 Mon Sep 17 00:00:00 2001
From: johnnyjoygh
Date: Mon, 7 Sep 2026 09:29:50 +0800
Subject: [PATCH] feat(web): scope Spaces by URL path instead of a remembered
selection
The selected Space used to live in sessionStorage and ride along invisibly
with every collection route. It now lives in the URL: collection pages exist
both globally and beneath /spaces/:spaceUid, and the Space context derives
its scope from the current location.
- Add a Space route branch (Home, Explore, Calendar, Attachments) guarded by
a SpaceRoute that shows loading, unavailable, and retry states.
- Resolve collection routes through one matchPath-based helper built from the
route constants; build Space-scoped paths with buildCollectionPath.
- Fetch the route Space directly, seeded from the switcher list so pages are
not held behind a second round-trip.
- Turn the Space switcher into plain links that preserve the current
collection view and query.
- Drop the remembered-Space machinery: clearSelectedSpace/selectMemos,
the compose-placement policy, routeSupportsCollectionScope, and the
quick-find switchToAll flag are all implied by the URL now.
- Return from memo details to the full origin URL, including its Space.
- Add space.unavailable and space.back-to-memos strings; align the
Azerbaijani catalog with English.
---
web/src/components/AppSidebar/AppSidebar.tsx | 15 +-
.../components/AppSidebar/QuickFindDialog.tsx | 21 +-
.../components/AppSidebar/SpaceSwitcher.tsx | 32 +-
.../components/AppSidebar/ViewsSection.tsx | 5 +-
web/src/components/AppSidebar/routes.ts | 58 +---
.../AttachmentLibraryPrimitives.tsx | 8 +-
.../CalendarView/CalendarHeader.tsx | 7 +-
.../components/CalendarView/CalendarLink.tsx | 5 +-
.../components/CalendarView/CalendarView.tsx | 8 +-
.../MemoActionMenu/MemoActionMenu.tsx | 2 +-
web/src/components/MemoActionMenu/hooks.ts | 26 +-
web/src/components/MemoActionMenu/types.ts | 3 +-
web/src/components/MemoContent/Tag.tsx | 9 +-
.../MemoDetailSidebar/MemoDetailSidebar.tsx | 9 +-
.../MemoView/components/MemoHeader.tsx | 2 +-
web/src/components/MemoView/navigation.ts | 6 +-
web/src/contexts/GlobalMemoEditorContext.tsx | 15 +-
web/src/contexts/MemoFilterContext.tsx | 13 +-
web/src/contexts/SpaceContext.tsx | 176 +++--------
web/src/hooks/useSpaceQueries.ts | 25 +-
web/src/lib/memo-views.ts | 18 +-
web/src/locales/az.json | 3 +-
web/src/locales/en.json | 2 +
web/src/locales/zh-Hans.json | 4 +
web/src/locales/zh-Hant.json | 2 +
web/src/pages/Calendar.tsx | 11 +-
web/src/router/SpaceRoute.tsx | 42 +++
web/src/router/index.tsx | 23 +-
web/src/router/routes.ts | 51 +++
web/tests/app-sidebar-logo.test.tsx | 59 ++--
web/tests/app-sidebar-routes.test.ts | 49 +--
.../attachment-library-source-link.test.tsx | 6 +-
web/tests/global-memo-editor.test.tsx | 24 +-
web/tests/memo-action-menu.test.tsx | 2 +-
web/tests/memo-action-navigation.test.tsx | 39 +--
web/tests/memo-detail-sidebar.test.tsx | 6 +-
web/tests/memo-filter-context.test.tsx | 29 ++
web/tests/memo-tag-navigation.test.tsx | 20 +-
web/tests/quick-find-navigation.test.tsx | 28 +-
web/tests/quick-find.test.ts | 13 +-
web/tests/router-config.test.tsx | 22 +-
web/tests/space-context.test.tsx | 290 +++++-------------
web/tests/space-queries.test.tsx | 36 ++-
web/tests/space-routes.test.tsx | 131 ++++++++
web/tests/space-switcher.test.tsx | 35 ++-
45 files changed, 704 insertions(+), 686 deletions(-)
create mode 100644 web/src/router/SpaceRoute.tsx
create mode 100644 web/tests/space-routes.test.tsx
diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx
index 5e681a7c..51e950f5 100644
--- a/web/src/components/AppSidebar/AppSidebar.tsx
+++ b/web/src/components/AppSidebar/AppSidebar.tsx
@@ -46,13 +46,13 @@ import { useNotifications, useUser } from "@/hooks/useUserQueries";
import { getMemoScopePath, getProfileUsername, type PrimaryMemoScope, resolveMemoScope } from "@/lib/memo-views";
import { userNamePrefix } from "@/lib/resource-names";
import { cn } from "@/lib/utils";
-import { ROUTES } from "@/router/routes";
+import { collectionPathForLocation, ROUTES } from "@/router/routes";
import { State } from "@/types/proto/api/v1/common_pb";
import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemosLogo from "../MemosLogo";
import CommonSidebarContent from "./CommonSidebarContent";
-import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes";
+import { getSidebarRouteKind } from "./routes";
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
import SpaceSwitcher from "./SpaceSwitcher";
@@ -368,7 +368,7 @@ const GlobalNavigation = () => {
const navigateToScope = (scope: PrimaryMemoScope) => {
setMemoScope(scope);
- navigate({ pathname: getMemoScopePath(scope), search: getFilterSearch(filters) });
+ navigate({ pathname: collectionPathForLocation(getMemoScopePath(scope), location.pathname), search: getFilterSearch(filters) });
setMobileOpen(false);
};
@@ -377,14 +377,14 @@ const GlobalNavigation = () => {
{
id: "calendar",
label: t("common.calendar"),
- path: ROUTES.CALENDAR,
+ path: collectionPathForLocation(ROUTES.CALENDAR, location.pathname),
icon: CalendarDaysIcon,
active: routeKind === "calendar",
},
{
id: "attachments",
label: t("common.attachments"),
- path: ROUTES.ATTACHMENTS,
+ path: collectionPathForLocation(ROUTES.ATTACHMENTS, location.pathname),
icon: PaperclipIcon,
active: routeKind === "attachments",
},
@@ -541,12 +541,11 @@ const GlobalNavigation = () => {
);
};
-/** The sidebar/header brand slot: collection scope on collection routes, instance brand elsewhere. */
+/** Signed-in users can navigate between Spaces from any page; global pages show Memos. */
const SidebarBrand = ({ className, size = "md" }: { className?: string; size?: "md" | "header" }) => {
const currentUser = useCurrentUser();
- const location = useLocation();
- if (currentUser && routeSupportsCollectionScope(location.pathname)) {
+ if (currentUser) {
return ;
}
diff --git a/web/src/components/AppSidebar/QuickFindDialog.tsx b/web/src/components/AppSidebar/QuickFindDialog.tsx
index db13f88c..db22c3da 100644
--- a/web/src/components/AppSidebar/QuickFindDialog.tsx
+++ b/web/src/components/AppSidebar/QuickFindDialog.tsx
@@ -51,7 +51,6 @@ export const readQuickFindQuery = (filters: MemoFilter[]): { query: string; mode
export interface QuickFindSubmission {
filters: MemoFilter[];
destination?: string;
- switchToAll: boolean;
}
export const resolveQuickFindSubmission = (
@@ -65,7 +64,6 @@ export const resolveQuickFindSubmission = (
return {
filters,
destination: routePolicy.searchDestination ? `${routePolicy.searchDestination}${getFilterSearch(filters)}` : undefined,
- switchToAll: routePolicy.searchScope === "all",
};
};
@@ -84,7 +82,7 @@ const QuickFindDialog = () => {
const currentUser = useCurrentUser();
const { data: memoViews = [] } = useMemoViews(currentUser?.name);
const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext();
- const { clearSelectedSpace, duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext();
+ const { duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext();
const { quickFindOpen, setQuickFindOpen } = useAppSidebar();
const [query, setQuery] = useState("");
const [mode, setMode] = useState("text");
@@ -93,7 +91,6 @@ const QuickFindDialog = () => {
const selectedMemoView = viewApplies ? memoViews.find((item) => getMemoViewId(item.name) === memoView) : undefined;
const lensLabel =
viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t);
- const routePolicy = getRouteActionPolicy(location.pathname);
const selectedSpaceUid = selectedSpaceName ? extractSpaceUidFromName(selectedSpaceName) : "";
const selectedSpaceUidDisplay = selectedSpaceName ? formatSpaceUidForDisplay(selectedSpaceName) : "";
const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName);
@@ -103,10 +100,8 @@ const QuickFindDialog = () => {
const compactSelectedSpaceLabel = `${selectedSpace?.title || t("space.current")}${
showSelectedSpaceUid && selectedSpaceUidDisplay ? ` (${selectedSpaceUidDisplay})` : ""
}`;
- const scopeLabel =
- routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${selectedSpaceLabel} · ${lensLabel}` : lensLabel;
- const compactScopeLabel =
- routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${compactSelectedSpaceLabel} · ${lensLabel}` : lensLabel;
+ const scopeLabel = selectedSpaceName ? `${selectedSpaceLabel} · ${lensLabel}` : lensLabel;
+ const compactScopeLabel = selectedSpaceName ? `${compactSelectedSpaceLabel} · ${lensLabel}` : lensLabel;
useEffect(() => {
if (!quickFindOpen) return;
@@ -118,17 +113,11 @@ const QuickFindDialog = () => {
const submitQuery = () => {
const submission = resolveQuickFindSubmission(location.pathname, query, filters, mode);
- if (submission.switchToAll) {
- // This is an explicit cross-Space action, so switch the collection state
- // to All without inserting an intermediate Home history entry.
- clearSelectedSpace();
- }
-
- setFilters(submission.filters);
-
if (submission.destination) {
setMemoView(undefined);
navigate(submission.destination);
+ } else {
+ setFilters(submission.filters);
}
setQuickFindOpen(false);
diff --git a/web/src/components/AppSidebar/SpaceSwitcher.tsx b/web/src/components/AppSidebar/SpaceSwitcher.tsx
index 0da27384..52cb7815 100644
--- a/web/src/components/AppSidebar/SpaceSwitcher.tsx
+++ b/web/src/components/AppSidebar/SpaceSwitcher.tsx
@@ -1,5 +1,6 @@
import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
+import { Link, useLocation } from "react-router-dom";
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import MemosLogo from "@/components/MemosLogo";
import SpaceMark from "@/components/SpaceMark";
@@ -15,6 +16,7 @@ import {
import { useSpaceContext } from "@/contexts/SpaceContext";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { cn } from "@/lib/utils";
+import { getSpaceSwitchPath } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
import { sidebarSurfaceVariants } from "./sidebar-layout";
@@ -26,24 +28,21 @@ const RowIcon = ({ icon: Icon, className }: { icon: LucideIcon; className?: stri
);
-const ContextItem = ({
- selected,
- onSelect,
- children,
- ariaLabel,
-}: {
- selected: boolean;
- onSelect: () => void;
- children: ReactNode;
- ariaLabel?: string;
-}) => (
+const ContextItem = ({ selected, to, children, ariaLabel }: { selected: boolean; to: string; children: ReactNode; ariaLabel?: string }) => (
{
+ if (selected && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) event.preventDefault();
+ }}
+ />
+ }
className={cn("min-w-0", selected && "bg-accent/60")}
>
{children}
@@ -53,8 +52,9 @@ const ContextItem = ({
function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: "md" | "header" }) {
const t = useTranslate();
- const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } =
- useSpaceContext();
+ const location = useLocation();
+ const memosPath = getSpaceSwitchPath(location);
+ const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectSpace } = useSpaceContext();
const [createOpen, setCreateOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState();
const triggerRef = useRef(null);
@@ -141,7 +141,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
style={menuWidth ? { width: `${menuWidth}px` } : undefined}
>
-
+
@@ -157,7 +157,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
selectSpace(space)}
+ to={getSpaceSwitchPath(location, space.name)}
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
>
diff --git a/web/src/components/AppSidebar/ViewsSection.tsx b/web/src/components/AppSidebar/ViewsSection.tsx
index 96d95bdc..3a41c23c 100644
--- a/web/src/components/AppSidebar/ViewsSection.tsx
+++ b/web/src/components/AppSidebar/ViewsSection.tsx
@@ -15,7 +15,7 @@ import { useMemoViews, userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoCollectionRoute } from "@/lib/memo-views";
import { cn } from "@/lib/utils";
-import { ROUTES } from "@/router/routes";
+import { collectionPathForLocation, ROUTES } from "@/router/routes";
import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb";
import { useTranslate } from "@/utils/i18n";
import SidebarRow, {
@@ -47,7 +47,8 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
const handleView = (viewId: string) => {
setMemoView(selectedMemoView === viewId ? undefined : viewId);
- if (!isMemoCollectionRoute(location.pathname)) navigate(ROUTES.HOME);
+ if (!isMemoCollectionRoute(location.pathname))
+ navigate({ pathname: collectionPathForLocation(ROUTES.HOME, location.pathname), search: location.search });
setMobileOpen(false);
};
diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts
index 8ad3d3ab..d602f0de 100644
--- a/web/src/components/AppSidebar/routes.ts
+++ b/web/src/components/AppSidebar/routes.ts
@@ -1,21 +1,19 @@
import { matchPath } from "react-router-dom";
import { getProfileUsername, isCalendarRoute, isMemoScopeRoute, type MemoScope, resolveMemoScope } from "@/lib/memo-views";
-import { ROUTES } from "@/router/routes";
+import { collectionPathForLocation, ROUTES, resolveCollectionRoute } from "@/router/routes";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "calendar" | "attachments" | "inbox" | "settings" | "memo" | "common";
-export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all";
-export type RouteComposePlacement = "remembered-space" | "unassigned";
+export type RouteSearchScope = "route-collection" | "user-collection" | "profile" | "all";
export interface RouteActionPolicy {
searchScope: RouteSearchScope;
/** When absent, Quick Find stays on the current route. */
searchDestination?: string;
- composePlacement: RouteComposePlacement;
}
export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
- const normalizedPath = path.length > 1 ? path.replace(/\/+$/, "") : path;
+ const normalizedPath = resolveCollectionRoute(path).pathname;
if (isMemoScopeRoute(normalizedPath)) return resolveMemoScope(normalizedPath);
if (getProfileUsername(normalizedPath) !== undefined) return "profile";
if (matchPath(ROUTES.VIEWS, normalizedPath)) return "views";
@@ -27,56 +25,26 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
return "common";
};
-/** Routes whose collections are filtered by the remembered All / Space scope. */
-export const routeSupportsCollectionScope = (path: string): boolean => {
- const kind = getSidebarRouteKind(path);
- return kind === "home" || kind === "explore" || kind === "calendar" || kind === "attachments";
-};
-
/**
- * Keeps read/search scope and write placement explicit at route boundaries.
- * A remembered Space is ambient collection state, so global and canonical
- * resource routes must not silently use it as a creation target.
+ * Keeps read/search scope explicit at route boundaries. Compose placement needs no
+ * policy: the URL only carries a Space on collection pages.
*/
export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
const kind = getSidebarRouteKind(path);
- if (kind === "home" || kind === "explore") {
- return {
- searchScope: "remembered-collection",
- composePlacement: "remembered-space",
- };
- }
+ if (kind === "home" || kind === "explore") return { searchScope: "route-collection" };
+ if (kind === "archived") return { searchScope: "user-collection" };
- if (kind === "archived") {
- return {
- searchScope: "user-collection",
- composePlacement: "unassigned",
- };
- }
-
- // Calendar and attachments browse the remembered collection but are not memo lists
- // themselves, so a search leaves for Home and Compose keeps the remembered Space.
+ // Calendar and attachments browse the route collection but are not memo lists
+ // themselves, so a search leaves for the same collection's Home.
if (kind === "calendar" || kind === "attachments") {
- return {
- searchScope: "remembered-collection",
- searchDestination: ROUTES.HOME,
- composePlacement: "remembered-space",
- };
+ return { searchScope: "route-collection", searchDestination: collectionPathForLocation(ROUTES.HOME, path) };
}
if (kind === "profile") {
- return {
- searchScope: "profile",
- // Re-enter the memo list tab on the same profile (dropping ?view=map).
- searchDestination: path.length > 1 ? path.replace(/\/+$/, "") : path,
- composePlacement: "unassigned",
- };
+ // Re-enter the memo list tab on the same profile (dropping ?view=map).
+ return { searchScope: "profile", searchDestination: path.length > 1 ? path.replace(/\/+$/, "") : path };
}
- return {
- searchScope: "all",
- searchDestination: ROUTES.HOME,
- composePlacement: "unassigned",
- };
+ return { searchScope: "all", searchDestination: ROUTES.HOME };
};
diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx
index 600300f2..854e1b84 100644
--- a/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx
+++ b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx
@@ -1,10 +1,9 @@
import { ExternalLinkIcon } from "lucide-react";
-import { Link } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
import { createMemoNavigationState } from "@/components/MemoView/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
-import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
interface AttachmentMetadataLineProps {
@@ -50,6 +49,7 @@ export const AttachmentSourceChip = ({
memoName,
unlinkedLabelKey = "attachment-library.labels.not-linked",
}: AttachmentSourceChipProps) => {
+ const location = useLocation();
const t = useTranslate();
if (!memoName) {
@@ -63,9 +63,7 @@ export const AttachmentSourceChip = ({
return (
{t("attachment-library.labels.memo")}
diff --git a/web/src/components/CalendarView/CalendarHeader.tsx b/web/src/components/CalendarView/CalendarHeader.tsx
index 76a9bca9..b45a88a4 100644
--- a/web/src/components/CalendarView/CalendarHeader.tsx
+++ b/web/src/components/CalendarView/CalendarHeader.tsx
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { buttonVariants } from "@/components/ui/button";
import { addMonths } from "@/lib/calendar-utils";
import { cn } from "@/lib/utils";
+import { collectionPathForLocation } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
import { CalendarLink } from "./CalendarLink";
import { MonthPicker } from "./MonthPicker";
@@ -33,7 +34,7 @@ export const getTodayPath = (month: string, activeDate: string | undefined, toda
export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable }: CalendarHeaderProps) => {
const t = useTranslate();
const navigate = useNavigate();
- const { search } = useLocation();
+ const { pathname, search } = useLocation();
const todayOpen = activeDate === today;
const iconControlClassName = cn(buttonVariants({ variant: "quiet", size: "icon-compact" }));
@@ -55,7 +56,9 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable
type="button"
aria-pressed={todayOpen}
className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "ms-1.5")}
- onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })}
+ onClick={() =>
+ navigate({ pathname: collectionPathForLocation(getTodayPath(month, activeDate, today, closable), pathname), search })
+ }
>
{t("common.today")}
diff --git a/web/src/components/CalendarView/CalendarLink.tsx b/web/src/components/CalendarView/CalendarLink.tsx
index b7bb526b..96590c67 100644
--- a/web/src/components/CalendarView/CalendarLink.tsx
+++ b/web/src/components/CalendarView/CalendarLink.tsx
@@ -1,5 +1,6 @@
import { forwardRef } from "react";
import { Link, type LinkProps, useLocation } from "react-router-dom";
+import { collectionPathForLocation } from "@/router/routes";
export interface CalendarLinkProps extends Omit {
/** Pathname only; the current search string rides along so view and tag filters survive. */
@@ -12,8 +13,8 @@ export interface CalendarLinkProps extends Omit {
* pathname change.
*/
export const CalendarLink = forwardRef(({ to, ...props }, ref) => {
- const { search } = useLocation();
- return ;
+ const { pathname, search } = useLocation();
+ return ;
});
CalendarLink.displayName = "CalendarLink";
diff --git a/web/src/components/CalendarView/CalendarView.tsx b/web/src/components/CalendarView/CalendarView.tsx
index 4814f68d..b7d19271 100644
--- a/web/src/components/CalendarView/CalendarView.tsx
+++ b/web/src/components/CalendarView/CalendarView.tsx
@@ -15,6 +15,7 @@ import { formatMonthLabel, getToday } from "@/lib/calendar-utils";
import { combineCELFilters } from "@/lib/cel-filter";
import { buildMemoCreatorFilter } from "@/lib/resource-names";
import { isMemoBlurred } from "@/lib/tag";
+import { collectionPathForLocation } from "@/router/routes";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { CalendarGrid } from "./CalendarGrid";
@@ -50,7 +51,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
const t = useTranslate();
const { i18n } = useTranslation();
const navigate = useNavigate();
- const { search } = useLocation();
+ const { pathname, search } = useLocation();
const user = useCurrentUser();
const md = useMediaQuery("md");
const xl = useMediaQuery("xl");
@@ -90,7 +91,10 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
});
const monthLabel = useMemo(() => formatMonthLabel(month, i18n.language), [month, i18n.language]);
- const closeDay = useCallback(() => navigate({ pathname: buildCalendarPath(month), search }), [navigate, month, search]);
+ const closeDay = useCallback(
+ () => navigate({ pathname: collectionPathForLocation(buildCalendarPath(month), pathname), search }),
+ [navigate, month, search, pathname],
+ );
const today = getToday();
const activeDate = date ?? (md ? undefined : getDefaultDate(month, today));
diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
index 196c3754..853a4d31 100644
--- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx
+++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
@@ -56,7 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
- parentScope: props.parentScope,
+ parentPage: props.parentPage,
onEdit: props.onEdit,
setDeleteDialogOpen,
});
diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts
index 599f3117..578034d9 100644
--- a/web/src/components/MemoActionMenu/hooks.ts
+++ b/web/src/components/MemoActionMenu/hooks.ts
@@ -4,7 +4,6 @@ import { useCallback } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
-import { useSpaceContext } from "@/contexts/SpaceContext";
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries";
@@ -14,22 +13,21 @@ import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
-import { isMemoDetailPath, type MemoOriginScope } from "../MemoView/navigation";
+import { isMemoDetailPath } from "../MemoView/navigation";
interface UseMemoActionHandlersOptions {
memo: Memo;
- parentScope: MemoOriginScope;
+ parentPage?: string;
onEdit?: () => void;
setDeleteDialogOpen: (open: boolean) => void;
}
-export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
+export const useMemoActionHandlers = ({ memo, parentPage, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const queryClient = useQueryClient();
const { profile } = useInstance();
- const { clearSelectedSpace } = useSpaceContext();
const { mutateAsync: updateMemo } = useUpdateMemo();
const { mutateAsync: deleteMemo } = useDeleteMemo();
const isInMemoDetailPage = isMemoDetailPath(location.pathname, memo.name);
@@ -149,24 +147,10 @@ export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDial
queryClient.invalidateQueries({ queryKey: memoKeys.detail(memo.parent) });
}
if (isInMemoDetailPage) {
- if (parentScope === "all") {
- clearSelectedSpace();
- }
- navigateTo(ROUTES.HOME);
+ navigateTo(parentPage || ROUTES.HOME);
}
memoUpdatedCallback();
- }, [
- memo.name,
- memo.parent,
- t,
- isInMemoDetailPage,
- parentScope,
- clearSelectedSpace,
- navigateTo,
- memoUpdatedCallback,
- deleteMemo,
- queryClient,
- ]);
+ }, [memo.name, memo.parent, t, isInMemoDetailPage, parentPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
return {
handleTogglePinMemoBtnClick,
diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts
index 0de0d0e5..78a864da 100644
--- a/web/src/components/MemoActionMenu/types.ts
+++ b/web/src/components/MemoActionMenu/types.ts
@@ -1,9 +1,8 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
-import type { MemoOriginScope } from "../MemoView/navigation";
export interface MemoActionMenuProps {
memo: Memo;
- parentScope: MemoOriginScope;
+ parentPage?: string;
readonly?: boolean;
onEdit?: () => void;
}
diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx
index 8be7c3b6..c8f9558a 100644
--- a/web/src/components/MemoContent/Tag.tsx
+++ b/web/src/components/MemoContent/Tag.tsx
@@ -2,7 +2,6 @@ import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
-import { useSpaceContext } from "@/contexts/SpaceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { tagStyles } from "@/lib/markdownStyles";
@@ -10,7 +9,7 @@ import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
-import { isMemoCollectionOrigin, isMemoResourcePath, withMemoFilter } from "../MemoView/navigation";
+import { isMemoResourcePath, withMemoFilter } from "../MemoView/navigation";
interface TagProps extends React.HTMLAttributes {
node?: Element; // AST node from react-markdown
@@ -19,10 +18,9 @@ interface TagProps extends React.HTMLAttributes {
}
export const Tag: React.FC = ({ "data-tag": dataTag, children, className, style, node: _node, ...props }) => {
- const { parentPage, parentScope } = useMemoViewContext();
+ const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
- const { clearSelectedSpace } = useSpaceContext();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { userTagsSetting } = useAuth();
@@ -47,9 +45,6 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (isMemoResourcePath(location.pathname)) {
- if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) {
- clearSelectedSpace();
- }
navigateTo(withMemoFilter(parentPage || Routes.HOME, stringifyFilters([{ factor: "tagSearch", value: tag }])));
return;
}
diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
index b0fed719..17fc5d30 100644
--- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
+++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
@@ -19,10 +19,9 @@ import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppS
import { extractHeadings } from "@/components/MemoContent/pipeline";
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
-import { createMemoNavigationState, isMemoCollectionOrigin, type MemoOriginScope } from "@/components/MemoView/navigation";
+import { createMemoNavigationState, type MemoOriginScope } from "@/components/MemoView/navigation";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useInstance } from "@/contexts/InstanceContext";
-import { useSpaceContext } from "@/contexts/SpaceContext";
import { useOverflowTitle } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { MEMO_COMMENTS_ANCHOR_ID } from "@/lib/memo-comments";
@@ -105,7 +104,6 @@ const MemoDetailSidebar = ({
const location = useLocation();
const currentUser = useCurrentUser();
const { profile } = useInstance();
- const { clearSelectedSpace } = useSpaceContext();
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const readonly = forceReadonly || (memo.creator !== currentUser?.name && !isSuperUser(currentUser));
@@ -183,11 +181,6 @@ const MemoDetailSidebar = ({
{
- if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) {
- clearSelectedSpace();
- }
- }}
viewTransition
>
diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx
index 897e4eb4..ca7abe08 100644
--- a/web/src/components/MemoView/components/MemoHeader.tsx
+++ b/web/src/components/MemoView/components/MemoHeader.tsx
@@ -130,7 +130,7 @@ const MemoHeader: React.FC = ({ timeDisplay = "relative", showC
)}
-
+
);
diff --git a/web/src/components/MemoView/navigation.ts b/web/src/components/MemoView/navigation.ts
index c1618cbe..be0a9931 100644
--- a/web/src/components/MemoView/navigation.ts
+++ b/web/src/components/MemoView/navigation.ts
@@ -1,5 +1,5 @@
import { getProfileUsername, isMemoScopeRoute } from "@/lib/memo-views";
-import { ROUTES } from "@/router/routes";
+import { ROUTES, resolveCollectionRoute } from "@/router/routes";
export type MemoOriginScope = "all" | "preserve";
@@ -40,10 +40,10 @@ export const isMemoResourcePath = (pathname: string): boolean => {
return memoID.length > 0 && !memoID.includes("/");
};
-/** Whether returning from this collection should preserve the remembered All / Space state. */
+/** Whether this origin represents a collection whose complete URL should be preserved. */
export const isMemoCollectionOrigin = (page: string): boolean => {
const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
- return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS;
+ return isMemoScopeRoute(pathname) || resolveCollectionRoute(pathname).isCollection;
};
export const createMemoNavigationState = (from: string, fromScope: MemoOriginScope): MemoNavigationState => ({ from, fromScope });
diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx
index e1395a20..0bfae71b 100644
--- a/web/src/contexts/GlobalMemoEditorContext.tsx
+++ b/web/src/contexts/GlobalMemoEditorContext.tsx
@@ -1,7 +1,7 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
-import { getRouteActionPolicy } from "@/components/AppSidebar/routes";
+import { getSidebarRouteKind } from "@/components/AppSidebar/routes";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
@@ -11,7 +11,6 @@ import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { spaceScopedCacheKey } from "@/lib/resource-names";
-import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
interface GlobalMemoEditorContextValue {
@@ -44,14 +43,12 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
const t = useTranslate();
const location = useLocation();
const currentUserName = useCurrentUser()?.name;
- const { selectedSpaceName } = useSpaceContext();
+ const { selectedSpaceName, isSpaceReady } = useSpaceContext();
const { isUserSettingsInitialized } = useAuth();
const desktop = useMediaQuery("md");
const [initialHome] = useState(() => ({ location, user: currentUserName, space: selectedSpaceName }));
- const homeAutoFocusPending = useRef(desktop && location.pathname === ROUTES.HOME && Boolean(currentUserName));
+ const homeAutoFocusPending = useRef(desktop && getSidebarRouteKind(location.pathname) === "home" && Boolean(currentUserName));
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
- const routePolicy = getRouteActionPolicy(location.pathname);
- const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined;
// One snapshot taken when the composer opens: keyed by the user who opened it, so
// signing out closes the composer in the same render and a different user signing
// in cannot resurrect it, and pinned to the Space that was selected at that moment.
@@ -97,7 +94,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
// The editor reads defaults out of user settings, so composing has to wait for
// them. Keep the rule here so every entry point uses the same gate.
- const canOpen = Boolean(currentUserName) && isUserSettingsInitialized;
+ const canOpen = Boolean(currentUserName) && isUserSettingsInitialized && isSpaceReady;
const openEditor = useCallback(() => {
if (!canOpen || !currentUserName) return;
@@ -116,10 +113,10 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
.then(({ default: MemoEditor }) => {
if (openRequestVersionRef.current !== requestVersion) return;
setEditorComponent(() => MemoEditor);
- setOpened({ user: currentUserName, space: composeSpace });
+ setOpened({ user: currentUserName, space: selectedSpaceName });
})
.catch(() => undefined);
- }, [canOpen, composeSpace, currentUserName, setMobileOpen, setQuickFindOpen]);
+ }, [canOpen, selectedSpaceName, currentUserName, setMobileOpen, setQuickFindOpen]);
useEffect(() => {
// RootLayout remains mounted when a public instance moves from Home to
diff --git a/web/src/contexts/MemoFilterContext.tsx b/web/src/contexts/MemoFilterContext.tsx
index d87dc950..7e2711b1 100644
--- a/web/src/contexts/MemoFilterContext.tsx
+++ b/web/src/contexts/MemoFilterContext.tsx
@@ -1,6 +1,6 @@
import { uniqBy } from "lodash-es";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
-import { useSearchParams } from "react-router-dom";
+import { useLocation, useSearchParams } from "react-router-dom";
export type FilterFactor =
| "tagSearch"
@@ -74,6 +74,8 @@ const MemoFilterContext = createContext(null);
export function MemoFilterProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
+ const locationStateRef = useRef(useLocation().state);
+ const skipStoreSyncRef = useRef(false);
const lastSyncedUrlRef = useRef("");
const lastSyncedStoreRef = useRef("");
@@ -87,6 +89,9 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const filterParam = searchParams.get("filter") || "";
if (filterParam !== lastSyncedUrlRef.current) {
+ // The store still contains the previous page’s filters in this commit.
+ // Do not write them back over the destination URL before state catches up.
+ skipStoreSyncRef.current = true;
lastSyncedUrlRef.current = filterParam;
const newFilters = parseFilterQuery(filterParam);
setFiltersState(newFilters);
@@ -96,6 +101,10 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) {
// Sync state to URL when state changes
useEffect(() => {
+ if (skipStoreSyncRef.current) {
+ skipStoreSyncRef.current = false;
+ return;
+ }
const storeString = stringifyFilters(filters);
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
lastSyncedStoreRef.current = storeString;
@@ -105,7 +114,7 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) {
} else {
newParams.delete("filter");
}
- setSearchParams(newParams, { replace: true });
+ setSearchParams(newParams, { replace: true, state: locationStateRef.current });
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
}
}, [filters, searchParams, setSearchParams]);
diff --git a/web/src/contexts/SpaceContext.tsx b/web/src/contexts/SpaceContext.tsx
index 6fd28122..85a23fb5 100644
--- a/web/src/contexts/SpaceContext.tsx
+++ b/web/src/contexts/SpaceContext.tsx
@@ -1,39 +1,13 @@
-import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import type { ConnectError } from "@connectrpc/connect";
+import { createContext, type ReactNode, useCallback, useContext, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
-import { routeSupportsCollectionScope } from "@/components/AppSidebar/routes";
import useCurrentUser from "@/hooks/useCurrentUser";
-import { useSpaces } from "@/hooks/useSpaceQueries";
+import { useSpace, useSpaces } from "@/hooks/useSpaceQueries";
import { buildCollectionScopeFilter, type CollectionScope } from "@/lib/cel-filter";
import { getDuplicateSpaceTitles } from "@/lib/space-display";
-import { ROUTES } from "@/router/routes";
+import { buildCollectionPath, ROUTES, resolveCollectionRoute } from "@/router/routes";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
-const SELECTED_SPACE_STORAGE_PREFIX = "memos-selected-space:";
-const ALL_COLLECTION_SCOPE: CollectionScope = { kind: "all" };
-
-export const getSelectedSpaceStorageKey = (userName: string) => `${SELECTED_SPACE_STORAGE_PREFIX}${userName}`;
-
-const readSelectedSpaceName = (userName: string): string | undefined => {
- try {
- return sessionStorage.getItem(getSelectedSpaceStorageKey(userName)) || undefined;
- } catch {
- return undefined;
- }
-};
-
-const writeSelectedSpaceName = (userName: string, spaceName: string | undefined) => {
- try {
- const key = getSelectedSpaceStorageKey(userName);
- if (spaceName) {
- sessionStorage.setItem(key, spaceName);
- } else {
- sessionStorage.removeItem(key);
- }
- } catch {
- // sessionStorage can be unavailable in restricted browser contexts.
- }
-};
-
interface SpaceContextValue {
spaces: Space[];
spaceByName: ReadonlyMap;
@@ -44,90 +18,39 @@ interface SpaceContextValue {
memoFilter?: string;
isLoadingSpaces: boolean;
isSpacesError: boolean;
- /** Selects All without changing the current route. */
- clearSelectedSpace: () => void;
+ isSpaceReady: boolean;
+ spaceError: ConnectError | null;
+ retrySpace: () => void;
+ /** Opens the Space Home after creation or from an invitation. */
selectSpace: (space: Space) => void;
- selectMemos: () => void;
}
const SpaceContext = createContext(null);
-
-// Stable identity for the pre-load and error states, so the memoized context value
-// below does not rebuild — and re-render every consumer — on each provider render.
const NO_SPACES: Space[] = [];
-const NO_SPACE_BY_NAME: ReadonlyMap = new Map();
-const NO_DUPLICATE_SPACE_TITLES: ReadonlySet = new Set();
-function UserSpaceSession({ userName, children }: { userName: string; children: ReactNode }) {
- // Keep router values in refs so switching scope does not make the context callbacks
- // change identity whenever the user navigates.
+export function SpaceProvider({ children }: { children: ReactNode }) {
+ const userName = useCurrentUser()?.name;
const location = useLocation();
const navigate = useNavigate();
- const pathnameRef = useRef(location.pathname);
- const navigateRef = useRef(navigate);
- pathnameRef.current = location.pathname;
- navigateRef.current = navigate;
-
- const [selectedSpaceName, setSelectedSpaceName] = useState(() => readSelectedSpaceName(userName));
- const [optimisticSpace, setOptimisticSpace] = useState();
+ const { spaceName: selectedSpaceName } = resolveCollectionRoute(location.pathname);
const spacesQuery = useSpaces(userName);
+ const spaceQuery = useSpace(userName, selectedSpaceName);
const spaces = spacesQuery.data ?? NO_SPACES;
- const spaceByName = useMemo(() => new Map(spaces.map((space) => [space.name, space])), [spaces]);
- const listedSelectedSpace = spaces.find((space) => space.name === selectedSpaceName);
- const selectedSpace = listedSelectedSpace ?? (optimisticSpace?.name === selectedSpaceName ? optimisticSpace : undefined);
- const duplicateSpaceTitles = useMemo(
- () => getDuplicateSpaceTitles(selectedSpace && !spaceByName.has(selectedSpace.name) ? [...spaces, selectedSpace] : spaces),
- [selectedSpace, spaceByName, spaces],
- );
+ const selectedSpace = spaceQuery.data;
+ const spaceByName = useMemo(() => {
+ const result = new Map(spaces.map((space) => [space.name, space]));
+ if (selectedSpace) result.set(selectedSpace.name, selectedSpace);
+ return result;
+ }, [spaces, selectedSpace]);
+ const duplicateSpaceTitles = useMemo(() => getDuplicateSpaceTitles([...spaceByName.values()]), [spaceByName]);
const collectionScope = useMemo(
- () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : ALL_COLLECTION_SCOPE),
+ () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : { kind: "all" }),
[selectedSpaceName],
);
-
- useEffect(() => {
- if (listedSelectedSpace && optimisticSpace?.name === listedSelectedSpace.name) {
- setOptimisticSpace(undefined);
- }
- }, [listedSelectedSpace, optimisticSpace]);
-
- useEffect(() => {
- if (!selectedSpaceName || !spacesQuery.isSuccess || selectedSpace) {
- return;
- }
-
- writeSelectedSpaceName(userName, undefined);
- setSelectedSpaceName(undefined);
- }, [selectedSpace, selectedSpaceName, spacesQuery.isSuccess, userName]);
-
- const navigateAfterScopeChange = useCallback(() => {
- // Scope and collection lens are independent. Preserve the active lens when
- // switching All/Space; global and resource routes fall back to My memos.
- if (!routeSupportsCollectionScope(pathnameRef.current)) {
- navigateRef.current(ROUTES.HOME);
- }
- }, []);
-
- const selectSpace = useCallback(
- (space: Space) => {
- writeSelectedSpaceName(userName, space.name);
- setOptimisticSpace(space);
- setSelectedSpaceName(space.name);
- navigateAfterScopeChange();
- },
- [navigateAfterScopeChange, userName],
- );
-
- const clearSelectedSpace = useCallback(() => {
- writeSelectedSpaceName(userName, undefined);
- setOptimisticSpace(undefined);
- setSelectedSpaceName(undefined);
- }, [userName]);
-
- const selectMemos = useCallback(() => {
- clearSelectedSpace();
- navigateAfterScopeChange();
- }, [clearSelectedSpace, navigateAfterScopeChange]);
-
+ const selectSpace = useCallback((space: Space) => navigate(buildCollectionPath(ROUTES.HOME, space.name)), [navigate]);
+ const retrySpace = useCallback(() => {
+ void spaceQuery.refetch();
+ }, [spaceQuery.refetch]);
const value = useMemo(
() => ({
spaces,
@@ -139,58 +62,33 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
memoFilter: buildCollectionScopeFilter(collectionScope),
isLoadingSpaces: spacesQuery.isPending,
isSpacesError: spacesQuery.isError,
- clearSelectedSpace,
+ isSpaceReady: !selectedSpaceName || (Boolean(userName) && spaceQuery.isSuccess),
+ spaceError: spaceQuery.error,
+ retrySpace,
selectSpace,
- selectMemos,
}),
[
- clearSelectedSpace,
- collectionScope,
+ spaces,
+ spaceByName,
duplicateSpaceTitles,
- selectMemos,
- selectSpace,
selectedSpace,
selectedSpaceName,
- spaceByName,
- spaces,
- spacesQuery.isError,
+ collectionScope,
spacesQuery.isPending,
+ spacesQuery.isError,
+ userName,
+ spaceQuery.isSuccess,
+ spaceQuery.error,
+ retrySpace,
+ selectSpace,
],
);
return {children};
}
-const anonymousValue: SpaceContextValue = {
- spaces: [],
- spaceByName: NO_SPACE_BY_NAME,
- duplicateSpaceTitles: NO_DUPLICATE_SPACE_TITLES,
- collectionScope: ALL_COLLECTION_SCOPE,
- isLoadingSpaces: false,
- isSpacesError: false,
- clearSelectedSpace: () => undefined,
- selectSpace: () => undefined,
- selectMemos: () => undefined,
-};
-
-export function SpaceProvider({ children }: { children: ReactNode }) {
- const currentUserName = useCurrentUser()?.name;
-
- if (!currentUserName) {
- return {children};
- }
-
- return (
-
- {children}
-
- );
-}
-
export function useSpaceContext() {
const context = useContext(SpaceContext);
- if (!context) {
- throw new Error("useSpaceContext must be used within SpaceProvider");
- }
+ if (!context) throw new Error("useSpaceContext must be used within SpaceProvider");
return context;
}
diff --git a/web/src/hooks/useSpaceQueries.ts b/web/src/hooks/useSpaceQueries.ts
index fa6624f0..80c2fbb0 100644
--- a/web/src/hooks/useSpaceQueries.ts
+++ b/web/src/hooks/useSpaceQueries.ts
@@ -1,10 +1,12 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
+import { Code, type ConnectError } from "@connectrpc/connect";
import { type QueryClient, type QueryKey, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { spaceServiceClient } from "@/connect";
import { attachmentKeys } from "@/hooks/useAttachmentQueries";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
+import { hasConnectCode } from "@/lib/error";
import {
type Space,
type SpaceInvitation,
@@ -90,7 +92,7 @@ const updateCachedList = (queryClient: QueryClient, queryKey: QueryKey, updat
const removeSpaceFromViewerCache = (queryClient: QueryClient, viewerName: string, spaceName: string) => {
updateCachedList(queryClient, spaceKeys.list(viewerName), (spaces) => removeByName(spaces, spaceName));
- queryClient.removeQueries({ queryKey: spaceKeys.space(viewerName, spaceName) });
+ queryClient.resetQueries({ queryKey: spaceKeys.space(viewerName, spaceName) });
};
const invalidateMembershipSensitiveQueries = (queryClient: QueryClient) => {
@@ -125,6 +127,22 @@ export function useSpaces(viewerName: string | undefined, options?: SpaceQueryOp
});
}
+/** Fetches the route Space; the switcher list seeds it so collection pages are not held behind a second round-trip. */
+export function useSpace(viewerName: string | undefined, spaceName: string | undefined) {
+ const queryClient = useQueryClient();
+ const listKey = spaceKeys.list(viewerName ?? "");
+ return useQuery({
+ queryKey: spaceKeys.space(viewerName ?? "", spaceName ?? ""),
+ queryFn: () => spaceServiceClient.getSpace({ name: spaceName! }),
+ enabled: queryEnabled(viewerName, spaceName),
+ initialData: () => queryClient.getQueryData(listKey)?.find((space) => space.name === spaceName),
+ initialDataUpdatedAt: () => queryClient.getQueryState(listKey)?.dataUpdatedAt,
+ staleTime: SPACE_LIST_STALE_TIME,
+ refetchOnWindowFocus: false,
+ retry: (count, error) => !hasConnectCode(error, Code.NotFound, Code.PermissionDenied) && count < 2,
+ });
+}
+
export function useSpaceMembers(viewerName: string | undefined, spaceName: string | undefined, options?: SpaceQueryOptions) {
return useQuery({
queryKey: spaceKeys.members(viewerName ?? "", spaceName ?? ""),
@@ -204,6 +222,7 @@ export function useCreateSpace(viewerName: string) {
spaceId,
}),
onSuccess: (space) => {
+ queryClient.setQueryData(spaceKeys.space(viewerName, space.name), space);
queryClient.setQueryData(spaceKeys.list(viewerName), (spaces = []) => upsertByName(spaces, space));
},
});
@@ -219,6 +238,7 @@ export function useUpdateSpace(viewerName: string) {
updateMask: create(FieldMaskSchema, { paths: updateMask }),
}),
onSuccess: (space) => {
+ queryClient.setQueryData(spaceKeys.space(viewerName, space.name), space);
updateCachedList(queryClient, spaceKeys.list(viewerName), (spaces) => upsertByName(spaces, space));
},
});
@@ -343,7 +363,8 @@ export function useUpdateSpaceMember(viewerName: string) {
if (spaceName) {
updateCachedList(queryClient, spaceKeys.members(viewerName, spaceName), (members) => upsertByName(members, member));
}
- if (member.user === viewerName) {
+ if (member.user === viewerName && spaceName) {
+ void queryClient.invalidateQueries({ queryKey: spaceKeys.space(viewerName, spaceName), exact: true });
void queryClient.invalidateQueries({ queryKey: spaceKeys.list(viewerName), exact: true });
}
},
diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts
index dfb967c9..d162fcc1 100644
--- a/web/src/lib/memo-views.ts
+++ b/web/src/lib/memo-views.ts
@@ -1,4 +1,4 @@
-import { ROUTES } from "@/router/routes";
+import { ROUTES, resolveCollectionRoute } from "@/router/routes";
export type MemoScope = "home" | "explore" | "archived";
export type PrimaryMemoScope = Exclude;
@@ -16,8 +16,11 @@ const cleanPathname = (value: string): string => {
return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
};
+/** Lower-cased global pathname, so a Space-scoped URL compares like its global twin. */
+const comparablePathname = (pathname: string): string => resolveCollectionRoute(pathname).pathname.toLowerCase();
+
export const isMemoScopeRoute = (pathname: string): boolean => {
- const comparablePath = cleanPathname(pathname).toLowerCase();
+ const comparablePath = comparablePathname(pathname);
return comparablePath === ROUTES.HOME || comparablePath === ROUTES.EXPLORE || comparablePath === ROUTES.ARCHIVED;
};
@@ -31,7 +34,7 @@ export const getProfileUsername = (pathname: string): string | undefined => {
/** `/calendar` and any month or day beneath it. */
export const isCalendarRoute = (pathname: string): boolean => {
- const comparablePath = cleanPathname(pathname).toLowerCase();
+ const comparablePath = comparablePathname(pathname);
return comparablePath === ROUTES.CALENDAR || comparablePath.startsWith(`${ROUTES.CALENDAR}/`);
};
@@ -39,15 +42,12 @@ export const isCalendarRoute = (pathname: string): boolean => {
* Routes that render a memo collection the sidebar can narrow: the scope routes, a user
* profile and the calendar. Views, calendar days and tags apply in place on all of them.
* This is a different question from `isMemoCollectionOrigin` (MemoView/navigation.ts),
- * which asks whether returning to a route should keep the remembered Space.
+ * which asks whether a memo origin is a collection whose full URL is worth returning to.
*/
export const isMemoCollectionRoute = (pathname: string): boolean =>
isMemoScopeRoute(pathname) || getProfileUsername(pathname) !== undefined || isCalendarRoute(pathname);
-export const getMemoScopePath = (scope: PrimaryMemoScope): string => {
- if (scope === "explore") return ROUTES.EXPLORE;
- return ROUTES.HOME;
-};
+export const getMemoScopePath = (scope: PrimaryMemoScope): string => (scope === "explore" ? ROUTES.EXPLORE : ROUTES.HOME);
interface ResolveMemoScopeOptions {
currentUsername?: string;
@@ -58,7 +58,7 @@ interface ResolveMemoScopeOptions {
export const resolveMemoScope = (pathname: string, options: ResolveMemoScopeOptions = {}): MemoScope => {
const cleanPath = cleanPathname(pathname);
- const comparablePath = cleanPath.toLowerCase();
+ const comparablePath = comparablePathname(cleanPath);
if (comparablePath === ROUTES.EXPLORE) return "explore";
if (comparablePath === ROUTES.ARCHIVED) return "archived";
if (comparablePath === ROUTES.HOME) return "home";
diff --git a/web/src/locales/az.json b/web/src/locales/az.json
index 361cb0cc..9a449a7b 100644
--- a/web/src/locales/az.json
+++ b/web/src/locales/az.json
@@ -54,7 +54,6 @@
"new-memo-on-day": "Bu gün üçün yeni qeyd",
"next-year": "Növbəti il",
"no-memos-in-month": "{{month}} ayında qeyd yoxdur",
- "no-memos-on-day": "Bu gün üçün qeyd yoxdur",
"previous-year": "Əvvəlki il",
"resize-panel": "Gün panelinin ölçüsünü dəyiş",
"select-month": "Ay seç"
@@ -1071,6 +1070,8 @@
}
},
"space": {
+ "unavailable": "Bu sahə mövcud deyil və ya ona giriş icazəniz yoxdur.",
+ "back-to-memos": "Memos-a qayıt",
"create": "Sahə yarat",
"create-description": "Qeydlər və üzvlər üçün ortaq yer yaradın.",
"create-success": "Sahə yaradıldı",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 99bd859a..9c793af0 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -1070,6 +1070,8 @@
}
},
"space": {
+ "unavailable": "This space does not exist or you don’t have access to it.",
+ "back-to-memos": "Back to Memos",
"create": "Create space",
"create-description": "Create a shared place for memos and members.",
"create-success": "Space created",
diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json
index 7f4232d0..b5da408c 100644
--- a/web/src/locales/zh-Hans.json
+++ b/web/src/locales/zh-Hans.json
@@ -966,5 +966,9 @@
"description": "这些文件从未附加到任何备忘。可在此查看或删除。",
"title": "未关联的上传"
}
+ },
+ "space": {
+ "unavailable": "空间不存在或你没有访问权限。",
+ "back-to-memos": "返回 Memos"
}
}
diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json
index 1c976726..294545c5 100644
--- a/web/src/locales/zh-Hant.json
+++ b/web/src/locales/zh-Hant.json
@@ -1046,6 +1046,8 @@
}
},
"space": {
+ "unavailable": "空間不存在或你沒有存取權限。",
+ "back-to-memos": "返回 Memos",
"create": "建立空間",
"create-description": "建立供備忘錄與成員共用的空間。",
"create-success": "已建立空間",
diff --git a/web/src/pages/Calendar.tsx b/web/src/pages/Calendar.tsx
index b41a1f4c..5f25772d 100644
--- a/web/src/pages/Calendar.tsx
+++ b/web/src/pages/Calendar.tsx
@@ -1,13 +1,20 @@
-import { Navigate, useParams } from "react-router-dom";
+import { Navigate, useLocation, useParams } from "react-router-dom";
import { buildCalendarPath, CalendarView, parseCalendarParams } from "@/components/CalendarView";
import type { CalendarRouteParams } from "@/components/CalendarView/paths";
import { getCurrentMonth } from "@/lib/calendar-utils";
+import { collectionPathForLocation } from "@/router/routes";
/** `/calendar/:year?/:month?/:day?` — anything that is not a real date lands on this month. */
const Calendar = () => {
+ const location = useLocation();
const state = parseCalendarParams(useParams());
if (!state) {
- return ;
+ return (
+
+ );
}
return ;
};
diff --git a/web/src/router/SpaceRoute.tsx b/web/src/router/SpaceRoute.tsx
new file mode 100644
index 00000000..b8f68b48
--- /dev/null
+++ b/web/src/router/SpaceRoute.tsx
@@ -0,0 +1,42 @@
+import { Code } from "@connectrpc/connect";
+import { LoaderCircleIcon } from "lucide-react";
+import { Link, Outlet } from "react-router-dom";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { useSpaceContext } from "@/contexts/SpaceContext";
+import { hasConnectCode } from "@/lib/error";
+import NotFound from "@/pages/NotFound";
+import { useTranslate } from "@/utils/i18n";
+import { ROUTES } from "./routes";
+
+/** Keeps inaccessible or unresolved Space content out of the page and composer. */
+export const SpaceRoute = () => {
+ const t = useTranslate();
+ const { selectedSpaceName, isSpaceReady, spaceError, retrySpace } = useSpaceContext();
+ if (!selectedSpaceName) return ;
+ if (isSpaceReady) return ;
+ const unavailable = hasConnectCode(spaceError, Code.NotFound, Code.PermissionDenied);
+ return (
+
+ {!spaceError ? (
+ <>
+
+ {t("space.loading")}
+ >
+ ) : (
+ <>
+ {t(unavailable ? "space.unavailable" : "space.load-error")}
+
+ {!unavailable && (
+
+ )}
+
+ {t("space.back-to-memos")}
+
+
+ >
+ )}
+
+ );
+};
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx
index 6c1b6b57..7c8308d7 100644
--- a/web/src/router/index.tsx
+++ b/web/src/router/index.tsx
@@ -11,7 +11,8 @@ import {
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "./guards";
-import { CALENDAR_ROUTE_PATTERN, ROUTES } from "./routes";
+import { CALENDAR_ROUTE_PATTERN, ROUTES, SPACE_ROUTE_PATTERN } from "./routes";
+import { SpaceRoute } from "./SpaceRoute";
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
const About = lazyWithReload(() => import("@/pages/About"));
@@ -107,6 +108,26 @@ export const routeConfig: RouteObject[] = [
{
element: ,
children: [
+ {
+ path: SPACE_ROUTE_PATTERN,
+ children: [
+ {
+ element: ,
+ children: [
+ {
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "explore", element: },
+ { path: "calendar/:year?/:month?/:day?", element: },
+ ],
+ },
+ { path: "attachments", element: },
+ ],
+ },
+ { path: "*", element: },
+ ],
+ },
{ path: Routes.ATTACHMENTS, element: },
{ path: Routes.INBOX, element: },
{ path: Routes.SETTING, element: },
diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts
index 3c798a4a..50afcc68 100644
--- a/web/src/router/routes.ts
+++ b/web/src/router/routes.ts
@@ -1,3 +1,6 @@
+import { matchPath } from "react-router-dom";
+import { extractSpaceUidFromName } from "@/lib/space-display";
+
export const ROUTES = {
HOME: "/",
ABOUT: "/about",
@@ -21,3 +24,51 @@ export const CALENDAR_ROUTE_PATTERN = `${ROUTES.CALENDAR}/:year?/:month?/:day?`;
export type RouteKey = keyof typeof ROUTES;
export type RoutePath = (typeof ROUTES)[RouteKey];
+
+export const SPACE_ROUTE_PATTERN = "/spaces/:spaceUid";
+
+/** Collection pages that exist both globally and beneath a Space. */
+const COLLECTION_ROUTE_PATTERNS = [ROUTES.HOME, ROUTES.EXPLORE, ROUTES.ATTACHMENTS, CALENDAR_ROUTE_PATTERN];
+
+const isCollectionPathname = (pathname: string): boolean =>
+ COLLECTION_ROUTE_PATTERNS.some((path) => matchPath({ path, caseSensitive: false }, pathname) !== null);
+
+export interface CollectionRoute {
+ spaceName?: string;
+ /** The global twin of a collection page; otherwise the normalized pathname. */
+ pathname: string;
+ isCollection: boolean;
+}
+
+/** The URL owns collection scope; unknown Space subroutes are not collection pages. */
+export const resolveCollectionRoute = (path: string): CollectionRoute => {
+ const pathname = (path.split(/[?#]/, 1)[0] || ROUTES.HOME).replace(/\/+$/, "") || ROUTES.HOME;
+ const spaceMatch = matchPath({ path: `${SPACE_ROUTE_PATTERN}/*`, caseSensitive: false }, pathname);
+ if (!spaceMatch) return { pathname, isCollection: isCollectionPathname(pathname) };
+ const uid = spaceMatch.params.spaceUid ?? "";
+ const collectionPath = `/${spaceMatch.params["*"] ?? ""}`;
+ // An encoded slash decodes into a UID that cannot round-trip through buildCollectionPath.
+ if (!uid || uid.includes("/") || !isCollectionPathname(collectionPath)) return { pathname, isCollection: false };
+ return { spaceName: `spaces/${uid}`, pathname: collectionPath, isCollection: true };
+};
+
+/** Builds a collection URL from a global collection pathname and an optional Space. */
+export const buildCollectionPath = (pathname: string, spaceName?: string): string => {
+ const route = resolveCollectionRoute(pathname);
+ if (!route.isCollection) return pathname;
+ if (!spaceName) return route.pathname;
+ const base = `/spaces/${encodeURIComponent(extractSpaceUidFromName(spaceName))}`;
+ return route.pathname === ROUTES.HOME ? base : `${base}${route.pathname}`;
+};
+
+/** Carries the current Space into another collection view. */
+export const collectionPathForLocation = (pathname: string, currentPath: string): string =>
+ buildCollectionPath(pathname, resolveCollectionRoute(currentPath).spaceName);
+
+/** Switching preserves collection views and their query; other pages start at Home. */
+export const getSpaceSwitchPath = (location: { pathname: string; search: string; hash?: string }, spaceName?: string): string => {
+ const route = resolveCollectionRoute(location.pathname);
+ return route.isCollection
+ ? `${buildCollectionPath(route.pathname, spaceName)}${location.search}${location.hash || ""}`
+ : buildCollectionPath(ROUTES.HOME, spaceName);
+};
diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx
index a0d0addb..80770e78 100644
--- a/web/tests/app-sidebar-logo.test.tsx
+++ b/web/tests/app-sidebar-logo.test.tsx
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
import { type MemoFilter, parseFilterQuery } from "@/contexts/MemoFilterContext";
+import { resolveCollectionRoute } from "@/router/routes";
const authState = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
@@ -24,10 +25,6 @@ const globalEditorState = vi.hoisted(() => ({
const spaceState = vi.hoisted(() => ({
spaces: [] as Array<{ name: string; title: string; description: string }>,
selectedSpace: undefined as { name: string; title: string; description: string } | undefined,
- selectedSpaceName: undefined as string | undefined,
- memoFilter: undefined as string | undefined,
- clearSelectedSpace: vi.fn(),
- selectMemos: vi.fn(),
selectSpace: vi.fn(),
}));
const filteredStatsHook = vi.hoisted(() => vi.fn());
@@ -105,6 +102,7 @@ vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => ({
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => {
+ const { spaceName } = resolveCollectionRoute(useLocation().pathname);
const duplicateSpaceTitles = new Set(
spaceState.spaces
.filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
@@ -112,6 +110,9 @@ vi.mock("@/contexts/SpaceContext", () => ({
);
return {
...spaceState,
+ selectedSpaceName: spaceName,
+ selectedSpace: spaceName ? spaceState.selectedSpace : undefined,
+ memoFilter: spaceName ? `space == ${JSON.stringify(spaceName)}` : undefined,
duplicateSpaceTitles,
isLoadingSpaces: false,
isSpacesError: false,
@@ -200,10 +201,6 @@ describe("App sidebar logo", () => {
globalEditorState.openEditor.mockClear();
spaceState.spaces = [];
spaceState.selectedSpace = undefined;
- spaceState.selectedSpaceName = undefined;
- spaceState.memoFilter = undefined;
- spaceState.clearSelectedSpace.mockClear();
- spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
filteredStatsHook.mockClear();
filterState.filters = [];
@@ -271,17 +268,15 @@ describe("App sidebar logo", () => {
});
it.each([
- "/",
- "/explore",
- "/attachments",
- "/Explore/",
- "/Attachments/",
+ "/spaces/product",
+ "/spaces/product/explore",
+ "/spaces/product/attachments",
+ "/spaces/product/Explore/",
+ "/spaces/product/Attachments/",
])("shows the selected Space only on collection route %s", (path) => {
const product = { name: "spaces/product", title: "Product", description: "" };
spaceState.spaces = [product];
spaceState.selectedSpace = product;
- spaceState.selectedSpaceName = product.name;
- spaceState.memoFilter = 'space == "spaces/product"';
render(
@@ -302,12 +297,10 @@ describe("App sidebar logo", () => {
"/memos/123",
"/memos/shares/token",
"/404",
- ])("uses the instance brand instead of the remembered Space on %s", (path) => {
+ ])("shows Memos in the switcher on global page %s", (path) => {
const product = { name: "spaces/product", title: "Product", description: "" };
spaceState.spaces = [product];
spaceState.selectedSpace = product;
- spaceState.selectedSpaceName = product.name;
- spaceState.memoFilter = 'space == "spaces/product"';
render(
@@ -315,19 +308,14 @@ describe("App sidebar logo", () => {
,
);
- const brand = screen.getByRole("link", { name: "Memos logo" });
- expect(brand).toHaveAttribute("href", "/");
+ const brand = screen.getByRole("button", { name: "space.switch: common.memos" });
expect(brand).toHaveClass("h-9", "gap-2", "px-2");
expect(within(brand).getByText("Memos logo")).toHaveAttribute("data-logo-size", "header");
- expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
});
it("scopes collection statistics to the selected Space", () => {
- spaceState.selectedSpaceName = "spaces/product";
- spaceState.memoFilter = 'space == "spaces/product"';
-
render(
-
+
,
);
@@ -345,10 +333,7 @@ describe("App sidebar logo", () => {
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: undefined }));
});
- it("keeps Profile statistics and tag UI state independent of the remembered Space", () => {
- spaceState.selectedSpaceName = "spaces/product";
- spaceState.memoFilter = 'space == "spaces/product"';
-
+ it("keeps Profile statistics and tag UI state unscoped by Space", () => {
render(
@@ -359,10 +344,7 @@ describe("App sidebar logo", () => {
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" }));
});
- it("keeps Archived statistics and tag UI state independent of the remembered Space", () => {
- spaceState.selectedSpaceName = "spaces/product";
- spaceState.memoFilter = 'space == "spaces/product"';
-
+ it("keeps Archived statistics and tag UI state unscoped by Space", () => {
render(
@@ -400,11 +382,8 @@ describe("App sidebar logo", () => {
});
it("hides the instance-level unused attachment collection in a Space", () => {
- spaceState.selectedSpaceName = "spaces/product";
- spaceState.memoFilter = 'space == "spaces/product"';
-
render(
-
+
,
);
@@ -475,7 +454,7 @@ describe("App sidebar logo", () => {
expect(screen.queryByRole("region", { name: "common.statistics" })).not.toBeInTheDocument();
expect(screen.queryByText("common.views")).not.toBeInTheDocument();
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
+ expect(screen.getByRole("button", { name: "space.switch: common.memos" })).toBeInTheDocument();
const navigation = within(screen.getByRole("navigation", { name: "Primary" }));
expectDefaultNavPill(navigation.getByRole("button", { name: "common.home" }), "common.home");
const attachments = navigation.getByRole("link", { name: "common.attachments" });
@@ -670,10 +649,8 @@ describe("App sidebar logo", () => {
);
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
- const mobileBrand = screen.getByRole("link", { name: "Memos logo" });
- expect(mobileBrand).toHaveAttribute("href", "/");
+ const mobileBrand = screen.getByRole("button", { name: "space.switch: common.memos" });
expect(mobileBrand).toHaveClass("h-9", "gap-1.5", "px-1");
- expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
});
diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts
index 183de7ef..2a98b972 100644
--- a/web/tests/app-sidebar-routes.test.ts
+++ b/web/tests/app-sidebar-routes.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { getRouteActionPolicy, getSidebarRouteKind, routeSupportsCollectionScope } from "@/components/AppSidebar/routes";
+import { getRouteActionPolicy, getSidebarRouteKind } from "@/components/AppSidebar/routes";
describe("sidebar route content", () => {
it.each([
@@ -33,55 +33,29 @@ describe("sidebar route content", () => {
expect(getSidebarRouteKind(path)).toBe(kind);
});
- it.each([
- ["/", true],
- ["/explore", true],
- ["/archived", false],
- ["/calendar", true],
- ["/calendar/2026/08/02", true],
- ["/attachments", true],
- ["/Explore/", true],
- ["/ARCHIVED/", false],
- ["/Attachments/", true],
- ["/u/steven", false],
- ["/inbox", false],
- ["/setting", false],
- ["/views", false],
- ["/about", false],
- ["/memos/abc", false],
- ["/memos/shares/token", false],
- ["/404", false],
- ])("reports whether %s supports the remembered collection scope", (path, expected) => {
- expect(routeSupportsCollectionScope(path)).toBe(expected);
- });
-
- it.each(["/", "/explore"])("keeps search and Compose in the remembered collection on %s", (path) => {
+ it.each(["/", "/explore"])("keeps search in the route collection on %s", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
- searchScope: "remembered-collection",
- composePlacement: "remembered-space",
+ searchScope: "route-collection",
});
});
- it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive without inheriting Space placement", (path) => {
+ it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "user-collection",
- composePlacement: "unassigned",
});
});
- it.each(["/attachments", "/calendar/2026/08/02"])("keeps the remembered scope when %s sends search to Home", (path) => {
+ it.each(["/attachments", "/calendar/2026/08/02"])("keeps the route scope when %s sends search to Home", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
- searchScope: "remembered-collection",
+ searchScope: "route-collection",
searchDestination: "/",
- composePlacement: "remembered-space",
});
});
- it("keeps Profile search on Profile but makes Compose unassigned", () => {
+ it("keeps Profile search on Profile", () => {
expect(getRouteActionPolicy("/u/steven")).toEqual({
searchScope: "profile",
searchDestination: "/u/steven",
- composePlacement: "unassigned",
});
});
@@ -89,15 +63,13 @@ describe("sidebar route content", () => {
expect(getRouteActionPolicy("/U/Steven/")).toEqual({
searchScope: "profile",
searchDestination: "/U/Steven",
- composePlacement: "unassigned",
});
});
- it("keeps a normalized Explore route in the remembered scope", () => {
+ it("keeps a normalized Explore route in the route scope", () => {
const path = "/Explore/";
expect(getRouteActionPolicy(path)).toEqual({
- searchScope: "remembered-collection",
- composePlacement: "remembered-space",
+ searchScope: "route-collection",
});
});
@@ -111,11 +83,10 @@ describe("sidebar route content", () => {
"/403",
"/404",
"/unknown",
- ])("sends search to All and makes Compose unassigned on %s", (path) => {
+ ])("sends search to All on %s", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "all",
searchDestination: "/",
- composePlacement: "unassigned",
});
});
});
diff --git a/web/tests/attachment-library-source-link.test.tsx b/web/tests/attachment-library-source-link.test.tsx
index c11adb03..8af19305 100644
--- a/web/tests/attachment-library-source-link.test.tsx
+++ b/web/tests/attachment-library-source-link.test.tsx
@@ -52,7 +52,7 @@ describe("", () => {
const onPreview = vi.fn();
render(
-
+
,
@@ -67,7 +67,9 @@ describe("", () => {
fireEvent.click(memoLink);
expect(onPreview).not.toHaveBeenCalled();
- expect(screen.getByTestId("location-state")).toHaveTextContent('{"from":"/","fromScope":"preserve"}');
+ expect(screen.getByTestId("location-state")).toHaveTextContent(
+ '{"from":"/spaces/product/attachments?filter=tagSearch%3Awork","fromScope":"preserve"}',
+ );
});
it("keeps video duration inside the existing play badge", () => {
diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx
index 8c8a35df..63d47e29 100644
--- a/web/tests/global-memo-editor.test.tsx
+++ b/web/tests/global-memo-editor.test.tsx
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
setMobileOpen: vi.fn(),
setQuickFindOpen: vi.fn(),
selectedSpaceName: undefined as string | undefined,
+ isSpaceReady: true,
pathname: "/",
desktop: true,
}));
@@ -36,7 +37,7 @@ vi.mock("@/contexts/AuthContext", () => ({
}));
vi.mock("@/contexts/SpaceContext", () => ({
- useSpaceContext: () => ({ selectedSpaceName: mocks.selectedSpaceName }),
+ useSpaceContext: () => ({ selectedSpaceName: mocks.selectedSpaceName, isSpaceReady: mocks.isSpaceReady }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
@@ -128,6 +129,7 @@ describe("GlobalMemoEditorProvider", () => {
mocks.setMobileOpen.mockClear();
mocks.setQuickFindOpen.mockClear();
mocks.selectedSpaceName = undefined;
+ mocks.isSpaceReady = true;
mocks.pathname = "/";
mocks.desktop = true;
});
@@ -173,6 +175,7 @@ describe("GlobalMemoEditorProvider", () => {
// Returning to the original route, Space, or viewport must not rearm focus.
mocks.pathname = "/";
mocks.selectedSpaceName = undefined;
+ mocks.isSpaceReady = true;
mocks.desktop = true;
rerender(
@@ -324,6 +327,15 @@ describe("GlobalMemoEditorProvider", () => {
expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
});
+ it("disables new memo while the route Space is unresolved or inaccessible", () => {
+ mocks.pathname = "/spaces/product";
+ mocks.selectedSpaceName = "spaces/product";
+ mocks.isSpaceReady = false;
+ renderProvider();
+ fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
+ expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
+ });
+
it("snapshots the selected Space when opening the composer", async () => {
mocks.selectedSpaceName = "spaces/product";
await openViaTrigger();
@@ -340,7 +352,11 @@ describe("GlobalMemoEditorProvider", () => {
});
});
- it.each(["/explore", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
+ it.each([
+ "/spaces/product/explore",
+ "/spaces/product/attachments",
+ "/spaces/product/calendar/2026/09",
+ ])("inherits the route Space when composing from %s", async (pathname) => {
mocks.pathname = pathname;
mocks.selectedSpaceName = "spaces/product";
await openViaTrigger();
@@ -363,9 +379,9 @@ describe("GlobalMemoEditorProvider", () => {
"/403",
"/404",
"/unknown",
- ])("creates an unassigned memo from %s even when a Space is remembered", async (pathname) => {
+ ])("creates an unassigned memo from %s", async (pathname) => {
+ // The URL only carries a Space on collection pages, so the context reports none here.
mocks.pathname = pathname;
- mocks.selectedSpaceName = "spaces/product";
await openViaTrigger();
expect(mocks.editorProps).toMatchObject({
diff --git a/web/tests/memo-action-menu.test.tsx b/web/tests/memo-action-menu.test.tsx
index 78704de7..ea92eab3 100644
--- a/web/tests/memo-action-menu.test.tsx
+++ b/web/tests/memo-action-menu.test.tsx
@@ -32,7 +32,7 @@ vi.mock("@/utils/i18n", () => ({
describe("MemoActionMenu", () => {
it("is a quiet compact control that takes the accent fill while open", async () => {
const memo = create(MemoSchema, { name: "memos/1", state: State.NORMAL, pinned: false });
- render();
+ render();
const trigger = screen.getByRole("button", { name: "common.more" });
expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "data-popup-open:bg-accent");
diff --git a/web/tests/memo-action-navigation.test.tsx b/web/tests/memo-action-navigation.test.tsx
index 4a190bd8..6de53723 100644
--- a/web/tests/memo-action-navigation.test.tsx
+++ b/web/tests/memo-action-navigation.test.tsx
@@ -4,14 +4,12 @@ import type { ReactNode } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useMemoActionHandlers } from "@/components/MemoActionMenu/hooks";
-import type { MemoOriginScope } from "@/components/MemoView/navigation";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const mocks = vi.hoisted(() => ({
updateMemo: vi.fn(),
deleteMemo: vi.fn(),
- clearSelectedSpace: vi.fn(),
}));
vi.mock("@/hooks/useMemoQueries", () => ({
@@ -31,10 +29,6 @@ vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ profile: { instanceUrl: "" } }),
}));
-vi.mock("@/contexts/SpaceContext", () => ({
- useSpaceContext: () => ({ clearSelectedSpace: mocks.clearSelectedSpace }),
-}));
-
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
@@ -51,7 +45,7 @@ const createMemo = (state: State, parent = ""): Memo =>
parent,
}) as Memo;
-const renderActions = (state: State, parentScope: MemoOriginScope, parent = "") => {
+const renderActions = (state: State, parent = "", parentPage?: string) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -65,10 +59,10 @@ const renderActions = (state: State, parentScope: MemoOriginScope, parent = "")
const location = useLocation();
const handlers = useMemoActionHandlers({
memo: createMemo(state, parent),
- parentScope,
+ parentPage,
setDeleteDialogOpen: vi.fn(),
});
- return { handlers, pathname: location.pathname };
+ return { handlers, pathname: location.pathname, search: location.search };
},
{ wrapper },
);
@@ -79,47 +73,40 @@ describe("Memo detail mutation navigation", () => {
beforeEach(() => {
mocks.updateMemo.mockReset().mockResolvedValue(undefined);
mocks.deleteMemo.mockReset().mockResolvedValue(undefined);
- mocks.clearSelectedSpace.mockReset();
});
it.each([
[State.NORMAL, "/archived"],
[State.ARCHIVED, "/"],
- ])("switches an All-origin %s memo to the user-level destination without changing the remembered Space", async (state, destination) => {
- const { result } = renderActions(state, "all");
+ ])("switches a %s memo to its user-level destination", async (state, destination) => {
+ const { result } = renderActions(state);
await act(async () => {
await result.current.handlers.handleToggleMemoStatusClick();
});
await waitFor(() => expect(result.current.pathname).toBe(destination));
- expect(mocks.clearSelectedSpace).not.toHaveBeenCalled();
});
- it("preserves an exact-Space origin when archiving", async () => {
- const { result } = renderActions(State.NORMAL, "preserve");
-
- await act(async () => {
- await result.current.handlers.handleToggleMemoStatusClick();
- });
-
- await waitFor(() => expect(result.current.pathname).toBe("/archived"));
- expect(mocks.clearSelectedSpace).not.toHaveBeenCalled();
+ it("returns to the full Space origin after deleting a memo detail", async () => {
+ const origin = "/spaces/product/calendar/2026/09/06?filter=tagSearch%3Awork";
+ const { result } = renderActions(State.NORMAL, "", origin);
+ await act(() => result.current.handlers.confirmDeleteMemo());
+ expect(result.current.pathname + result.current.search).toBe(origin);
});
- it("clears a stale Space before deleting from an All origin", async () => {
- const { result } = renderActions(State.NORMAL, "all");
+ it("returns directly to a global origin after deleting", async () => {
+ const { result } = renderActions(State.NORMAL);
await act(async () => {
await result.current.handlers.confirmDeleteMemo();
});
await waitFor(() => expect(result.current.pathname).toBe("/"));
- expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce();
});
it("refreshes a parent memo after deleting one of its comments", async () => {
- const { result, invalidateQueries } = renderActions(State.NORMAL, "preserve", "memos/parent");
+ const { result, invalidateQueries } = renderActions(State.NORMAL, "memos/parent");
await act(async () => {
await result.current.handlers.confirmDeleteMemo();
diff --git a/web/tests/memo-detail-sidebar.test.tsx b/web/tests/memo-detail-sidebar.test.tsx
index 39dbe720..73bf6bf2 100644
--- a/web/tests/memo-detail-sidebar.test.tsx
+++ b/web/tests/memo-detail-sidebar.test.tsx
@@ -16,7 +16,6 @@ import {
const copyToClipboard = vi.hoisted(() => vi.fn());
const currentUserState = vi.hoisted(() => ({ value: { name: "users/alice" } as { name: string } | undefined }));
-const clearSelectedSpace = vi.hoisted(() => vi.fn());
vi.mock("copy-to-clipboard", () => ({ default: copyToClipboard }));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn() } }));
@@ -26,7 +25,6 @@ vi.mock("@/components/MemoDetailSidebar/MemoOutline", () => ({
vi.mock("@/components/MemoDetailSidebar/MemoSharePanel", () => ({ default: () => }));
vi.mock("@/components/MemoMetadata/Relation/useResolvedRelationMemos", () => ({ useResolvedRelationMemos: () => ({}) }));
vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "https://memos.example/" } }) }));
-vi.mock("@/contexts/SpaceContext", () => ({ useSpaceContext: () => ({ clearSelectedSpace }) }));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => currentUserState.value }));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string, values?: { source?: string }) => (values?.source ? `${key}:${values.source}` : key),
@@ -45,7 +43,6 @@ const createIncomingReference = (memoName: string, sourceName = "memos/incoming"
describe("MemoDetailSidebar", () => {
beforeEach(() => {
copyToClipboard.mockReset();
- clearSelectedSpace.mockReset();
currentUserState.value = { name: "users/alice" };
});
@@ -155,8 +152,7 @@ describe("MemoDetailSidebar", () => {
expect(screen.getByRole("button", { name: "memo.comment.write-a-comment" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.referenced-by: Incoming backlink" })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("link", { name: "memo.go-to:common.explore" }));
- expect(clearSelectedSpace).toHaveBeenCalledOnce();
+ expect(screen.getByRole("link", { name: "memo.go-to:common.explore" })).toHaveAttribute("href", "/explore");
});
it("keeps a simple memo useful without rendering empty navigation or connections", () => {
diff --git a/web/tests/memo-filter-context.test.tsx b/web/tests/memo-filter-context.test.tsx
index 11449d5d..a0d96283 100644
--- a/web/tests/memo-filter-context.test.tsx
+++ b/web/tests/memo-filter-context.test.tsx
@@ -64,6 +64,35 @@ describe("MemoFilterProvider", () => {
expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected));
expect(new URLSearchParams(router.state.location.search).get("filter")).toBe(stringifyFilters(expected));
});
+ it("preserves the Space origin when navigation removes collection filters", async () => {
+ const origin = "/spaces/product?filter=tagSearch%3Awork";
+ const router = createMemoryRouter(
+ [
+ {
+ path: "*",
+ element: (
+
+
+
+ ),
+ },
+ ],
+ { initialEntries: [origin] },
+ );
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Select Tasks" }));
+ const state = { from: origin, fromScope: "preserve" };
+ await act(() => router.navigate("/memos/1", { state }));
+ await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]"));
+ expect(router.state.location.search).toBe("");
+ expect(router.state.location.state).toEqual(state);
+ await act(() => router.navigate(-1));
+ expect(router.state.location.pathname + router.state.location.search).toBe(origin);
+ expect(screen.getByTestId("filters")).toHaveTextContent('"work"');
+ await act(() => router.navigate("/spaces/research?filter=tagSearch%3Awork"));
+ expect(screen.getByTestId("memoView")).toHaveTextContent(BUILTIN_TASKS_VIEW_ID);
+ });
+
it("keeps encoded values containing colons intact", () => {
expect(parseFilterQuery("contentSearch:https://example.com:8080/path")).toEqual([
{ factor: "contentSearch", value: "https://example.com:8080/path" },
diff --git a/web/tests/memo-tag-navigation.test.tsx b/web/tests/memo-tag-navigation.test.tsx
index e38d9cec..ceea8a9a 100644
--- a/web/tests/memo-tag-navigation.test.tsx
+++ b/web/tests/memo-tag-navigation.test.tsx
@@ -4,11 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { Tag } from "@/components/MemoContent/Tag";
const navigateTo = vi.hoisted(() => vi.fn());
-const clearSelectedSpace = vi.hoisted(() => vi.fn());
-const origin = vi.hoisted(() => ({
- parentPage: "/" as string,
- parentScope: "all" as "all" | "preserve",
-}));
+const origin = vi.hoisted(() => ({ parentPage: "/" as string }));
vi.mock("@/hooks/useNavigateTo", () => ({
default: () => navigateTo,
@@ -18,10 +14,6 @@ vi.mock("@/components/MemoView/MemoViewContext", () => ({
useMemoViewContext: () => origin,
}));
-vi.mock("@/contexts/SpaceContext", () => ({
- useSpaceContext: () => ({ clearSelectedSpace }),
-}));
-
vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -41,12 +33,10 @@ vi.mock("@/contexts/AuthContext", () => ({
describe("Memo tag navigation", () => {
beforeEach(() => {
navigateTo.mockClear();
- clearSelectedSpace.mockClear();
origin.parentPage = "/";
- origin.parentScope = "all";
});
- it("switches to All only when a global detail tag enters a collection", () => {
+ it("navigates directly to the global collection from a global detail", () => {
render(
#work
@@ -54,12 +44,10 @@ describe("Memo tag navigation", () => {
);
fireEvent.click(screen.getByText("#work"));
-
- expect(clearSelectedSpace).toHaveBeenCalledOnce();
expect(navigateTo).toHaveBeenCalledWith("/?filter=tagSearch%3Awork");
});
- it("returns a Profile-origin tag without clearing the remembered Space", () => {
+ it("returns a Profile-origin tag to that profile's memo list", () => {
origin.parentPage = "/u/alice?view=map";
render(
@@ -69,8 +57,6 @@ describe("Memo tag navigation", () => {
);
fireEvent.click(screen.getByText("#work"));
-
- expect(clearSelectedSpace).not.toHaveBeenCalled();
expect(navigateTo).toHaveBeenCalledWith("/u/alice?filter=tagSearch%3Awork");
});
});
diff --git a/web/tests/quick-find-navigation.test.tsx b/web/tests/quick-find-navigation.test.tsx
index 32d5ae4d..9da1ef2f 100644
--- a/web/tests/quick-find-navigation.test.tsx
+++ b/web/tests/quick-find-navigation.test.tsx
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import QuickFindDialog from "@/components/AppSidebar/QuickFindDialog";
import MemoFilters from "@/components/MemoFilters";
import { AppSidebarProvider, useAppSidebar } from "@/contexts/AppSidebarContext";
-import { getSelectedSpaceStorageKey, SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext";
+import { SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext";
const state = vi.hoisted(() => ({
currentUser: { name: "users/alice" } as { name: string } | undefined,
@@ -35,6 +35,12 @@ vi.mock("@/hooks/useCurrentUser", () => ({
vi.mock("@/hooks/useSpaceQueries", () => ({
useSpaces: () => ({ data: state.spaces, isSuccess: true, isPending: false, isError: false }),
+ useSpace: (_user: string, name: string) => ({
+ data: state.spaces.find((space) => space.name === name),
+ isSuccess: true,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
vi.mock("@/hooks/useUserQueries", () => ({
@@ -163,22 +169,18 @@ describe("Quick Find navigation", () => {
expect(state.filters.filter((filter) => !predicate(filter))).toEqual([{ factor: "tagSearch", value: "work" }]);
});
- it("switches to All in one history step so Back returns directly to Inbox", async () => {
- const storageKey = getSelectedSpaceStorageKey("users/alice");
- sessionStorage.setItem(storageKey, "spaces/product");
-
+ it("searches from Inbox into global Home in one history step", async () => {
const router = renderSearch("/inbox");
- expect(screen.getByTestId("scope")).toHaveTextContent("spaces/product");
+ expect(screen.getByTestId("scope")).toHaveTextContent("all");
openQuickFind();
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "roadmap" } });
fireEvent.submit(input.closest("form")!);
await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch%3Aroadmap"));
- expect(state.setFilters).toHaveBeenCalledWith([{ factor: "contentSearch", value: "roadmap" }]);
+ expect(state.setFilters).not.toHaveBeenCalled();
expect(screen.getByTestId("scope")).toHaveTextContent("all");
- expect(sessionStorage.getItem(storageKey)).toBeNull();
await act(async () => {
await router.navigate(-1);
@@ -187,10 +189,8 @@ describe("Quick Find navigation", () => {
expect(screen.getByTestId("path")).toHaveTextContent("/inbox");
});
- it("keeps a unique selected Space title compact in remembered-collection search", async () => {
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), "spaces/product");
-
- renderSearch("/");
+ it("keeps a unique selected Space title compact in route-collection search", async () => {
+ renderSearch("/spaces/product");
openQuickFind();
const input = await screen.findByRole("textbox");
@@ -204,9 +204,7 @@ describe("Quick Find navigation", () => {
{ name: `spaces/${uuid}`, title: "Product", description: "" },
{ name: "spaces/product-roadmap", title: "Product", description: "" },
];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), `spaces/${uuid}`);
-
- renderSearch("/");
+ renderSearch(`/spaces/${uuid}`);
openQuickFind();
const input = await screen.findByRole("textbox");
diff --git a/web/tests/quick-find.test.ts b/web/tests/quick-find.test.ts
index 05b4a0ea..6ee5ccd2 100644
--- a/web/tests/quick-find.test.ts
+++ b/web/tests/quick-find.test.ts
@@ -22,7 +22,6 @@ describe("Quick Find", () => {
...scopedFilters.filter((filter) => filter.factor !== "contentSearch"),
{ factor: "celSearch", value: expression },
]);
- expect(submission.switchToAll).toBe(false);
});
it("replaces both kinds of search without dropping facets", () => {
@@ -66,11 +65,10 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "project" },
],
destination: undefined,
- switchToAll: false,
});
});
- it("searches Archived as a user collection without clearing the remembered Space", () => {
+ it("searches Archived as a user collection", () => {
expect(resolveQuickFindSubmission("/archived", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
@@ -78,11 +76,10 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "project" },
],
destination: undefined,
- switchToAll: false,
});
});
- it("keeps the remembered collection filters when searching from Attachments", () => {
+ it("keeps the collection filters when searching from Attachments", () => {
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
@@ -90,11 +87,10 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "project" },
],
destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
- switchToAll: false,
});
});
- it("keeps the remembered collection on a normalized Attachments route", () => {
+ it("keeps the collection on a normalized Attachments route", () => {
expect(resolveQuickFindSubmission("/Attachments/", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
@@ -102,7 +98,6 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "project" },
],
destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
- switchToAll: false,
});
});
@@ -114,7 +109,6 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "project" },
],
destination: "/u/steven?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
- switchToAll: false,
});
});
@@ -130,7 +124,6 @@ describe("Quick Find", () => {
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters, "text")).toEqual({
filters: [{ factor: "contentSearch", value: "project" }],
destination: "/?filter=contentSearch%3Aproject",
- switchToAll: true,
});
});
diff --git a/web/tests/router-config.test.tsx b/web/tests/router-config.test.tsx
index 8a6b4d58..1d5d5601 100644
--- a/web/tests/router-config.test.tsx
+++ b/web/tests/router-config.test.tsx
@@ -1,9 +1,10 @@
import { isValidElement } from "react";
-import type { RouteObject } from "react-router-dom";
+import { matchRoutes, type RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { ROUTES, routeConfig } from "@/router";
import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards";
-import { CALENDAR_ROUTE_PATTERN } from "@/router/routes";
+import { CALENDAR_ROUTE_PATTERN, SPACE_ROUTE_PATTERN } from "@/router/routes";
+import { SpaceRoute } from "@/router/SpaceRoute";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
@@ -40,6 +41,23 @@ function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unkno
}
describe("router configuration", () => {
+ it.each([
+ "/spaces/product",
+ "/spaces/product/explore",
+ "/spaces/product/calendar/2026/09",
+ "/spaces/product/attachments",
+ ])("gates %s with authentication and Space access", (path) => {
+ const types = matchRoutes(routeConfig, path)?.map(({ route }) => elementType(route));
+ expect(types).toContain(RequireAuthRoute);
+ expect(types).toContain(SpaceRoute);
+ expect(findByPath(routeConfig, SPACE_ROUTE_PATTERN)).toBeDefined();
+ });
+ it("keeps unknown Space subroutes outside collection pages", () => {
+ const matches = matchRoutes(routeConfig, "/spaces/product/unknown");
+ expect(matches?.at(-1)?.route.path).toBe("*");
+ expect(matches?.map(({ route }) => elementType(route))).not.toContain(SpaceRoute);
+ });
+
it("keeps /auth/callback outside the guest-only guard", () => {
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
// must not short-circuit the OAuth callback via RequireGuestRoute.
diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx
index f1844af6..20adb868 100644
--- a/web/tests/space-context.test.tsx
+++ b/web/tests/space-context.test.tsx
@@ -1,225 +1,97 @@
-import { create } from "@bufbuild/protobuf";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { MemoryRouter, useLocation } from "react-router-dom";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { createMemoryRouter, Link, Outlet, RouterProvider, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { getSelectedSpaceStorageKey, SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext";
-import { SpaceSchema } from "@/types/proto/api/v1/space_service_pb";
-
-const newlyCreatedSpace = create(SpaceSchema, { name: "spaces/new", title: "New", description: "" });
+import { SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext";
+import { getSpaceSwitchPath } from "@/router/routes";
const state = vi.hoisted(() => ({
- currentUser: { name: "users/alice" } as { name: string } | undefined,
- query: {
- data: [] as Array<{ name: string; title: string; description: string }>,
- isSuccess: true,
- isPending: false,
- isError: false,
- },
+ user: "users/alice",
+ spaces: [
+ { name: "spaces/a", title: "Same" },
+ { name: "spaces/b", title: "Same" },
+ ],
+ ready: true,
}));
-
-vi.mock("@/hooks/useCurrentUser", () => ({
- default: () => state.currentUser,
-}));
-
+vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: state.user }) }));
vi.mock("@/hooks/useSpaceQueries", () => ({
- useSpaces: () => state.query,
+ useSpaces: () => ({ data: state.spaces, isPending: false, isError: false }),
+ useSpace: (_user: string, name?: string) => ({
+ data: state.ready ? state.spaces.find((s) => s.name === name) : undefined,
+ isSuccess: state.ready,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
-
const Probe = () => {
- const {
- clearSelectedSpace,
- collectionScope,
- duplicateSpaceTitles,
- memoFilter,
- spaces,
- selectedSpace,
- selectedSpaceName,
- selectMemos,
- selectSpace,
- } = useSpaceContext();
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const CurrentPath = () => {
const location = useLocation();
- return ;
-};
-
-const renderProvider = (initialPath = "/explore") =>
- render(
-
-
-
-
- ,
+ const context = useSpaceContext();
+ return (
+ <>
+
+
+
+
+
+ B
+ Memos
+
+ >
);
-
-describe("SpaceProvider", () => {
+};
+const setup = (path = "/") => {
+ const router = createMemoryRouter(
+ [
+ {
+ path: "*",
+ element: (
+
+
+
+ ),
+ },
+ ],
+ { initialEntries: [path] },
+ );
+ render();
+ return router;
+};
+describe("URL-owned Space context", () => {
beforeEach(() => {
- sessionStorage.clear();
- state.currentUser = { name: "users/alice" };
- state.query = { data: [], isSuccess: true, isPending: false, isError: false };
+ state.ready = true;
+ state.user = "users/alice";
});
-
- it("uses the All collection when the user has no stored Space selection", () => {
- renderProvider();
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos");
- expect(screen.getByTestId("collection-scope")).toHaveTextContent("all");
- expect(screen.getByTestId("memo-filter")).toHaveTextContent("all");
+ it("reads All on global pages", () => {
+ setup("/explore");
+ expect(screen.getByTestId("space")).toHaveTextContent("all");
+ expect(screen.getByTestId("filter")).toHaveTextContent("all");
});
-
- it("restores a valid Space and stores changes only for the current user", () => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- state.query.data = [product];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- renderProvider();
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name);
- expect(screen.getByTestId("selected-title")).toHaveTextContent("Product");
- expect(screen.getByTestId("collection-scope")).toHaveTextContent("space:spaces/product");
- expect(screen.getByTestId("memo-filter")).toHaveTextContent('space == "spaces/product"');
-
- fireEvent.click(screen.getByRole("button", { name: "Select Memos" }));
- expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBeNull();
-
- fireEvent.click(screen.getByRole("button", { name: "Select first Space" }));
- expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(product.name);
+ it("restores Space from a deep link before metadata has loaded", () => {
+ state.ready = false;
+ setup("/spaces/a/calendar/2026/09/06");
+ expect(screen.getByTestId("space")).toHaveTextContent("spaces/a");
+ expect(screen.getByTestId("filter")).toHaveTextContent('space == "spaces/a"');
+ expect(screen.getByTestId("ready")).toHaveTextContent("false");
});
-
- it("clears a stored selection only after a successful list proves it unavailable", async () => {
- const key = getSelectedSpaceStorageKey("users/alice");
- sessionStorage.setItem(key, "spaces/removed");
- renderProvider();
-
- await waitFor(() => expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos"));
- expect(sessionStorage.getItem(key)).toBeNull();
+ it("preserves dates and filters through A, B, All and browser history", async () => {
+ const suffix = "/calendar/2026/09/06?filter=tagSearch%3Awork";
+ const router = setup(`/spaces/a${suffix}`);
+ fireEvent.click(screen.getByText("B"));
+ expect(screen.getByTestId("path").textContent).toBe(`/spaces/b${suffix}`);
+ expect(screen.getByTestId("space")).toHaveTextContent("spaces/b");
+ expect(screen.getByTestId("duplicates")).toHaveTextContent("Same");
+ fireEvent.click(screen.getByText("Memos"));
+ expect(screen.getByTestId("path").textContent).toBe(suffix);
+ await act(() => router.navigate(-1));
+ expect(screen.getByTestId("space")).toHaveTextContent("spaces/b");
+ await act(() => router.navigate(-1));
+ expect(screen.getByTestId("space")).toHaveTextContent("spaces/a");
+ await act(() => router.navigate(1));
+ expect(screen.getByTestId("space")).toHaveTextContent("spaces/b");
});
-
- it("preserves a stored selection when the Space list fails transiently", () => {
- const key = getSelectedSpaceStorageKey("users/alice");
- sessionStorage.setItem(key, "spaces/product");
- state.query = { data: [], isSuccess: false, isPending: false, isError: true };
- renderProvider();
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent("spaces/product");
- expect(sessionStorage.getItem(key)).toBe("spaces/product");
- });
-
- it("keeps a newly created Space selected until the refreshed list includes it", () => {
- renderProvider();
-
- fireEvent.click(screen.getByRole("button", { name: "Select new Space" }));
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent(newlyCreatedSpace.name);
- expect(screen.getByTestId("selected-title")).toHaveTextContent(newlyCreatedSpace.title);
- expect(screen.getByTestId("memo-filter")).toHaveTextContent('space == "spaces/new"');
- expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
- });
-
- it("includes an optimistic selected Space when deriving matching titles", () => {
- state.query.data = [{ name: "spaces/existing-new", title: "New", description: "" }];
- renderProvider();
-
- fireEvent.click(screen.getByRole("button", { name: "Select new Space" }));
-
- expect(screen.getByTestId("duplicate-titles")).toHaveTextContent("New");
- });
-
- it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- state.query.data = [product];
- renderProvider(initialPath);
-
- fireEvent.click(screen.getByRole("button", { name: "Select first Space" }));
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name);
- expect(screen.getByTestId("path").textContent).toBe(initialPath);
- });
-
- it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to All from %s", (initialPath) => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- state.query.data = [product];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- renderProvider(initialPath);
-
- fireEvent.click(screen.getByRole("button", { name: "Select Memos" }));
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos");
- expect(screen.getByTestId("path").textContent).toBe(initialPath);
- });
-
- it.each([
- ["Select first Space", "/inbox"],
- ["Select first Space", "/archived?filter=tagSearch%3Awork"],
- ["Select Memos", "/u/alice"],
- ["Select Memos", "/archived?filter=tagSearch%3Awork"],
- ])("falls back to Home when using %s outside a collection route", (action, initialPath) => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- state.query.data = [product];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- renderProvider(initialPath);
-
- fireEvent.click(screen.getByRole("button", { name: action }));
-
- expect(screen.getByTestId("path").textContent).toBe("/");
- });
-
- it("can select All on a resource route without navigating", () => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- state.query.data = [product];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- renderProvider("/memos/123");
-
- fireEvent.click(screen.getByRole("button", { name: "Clear Space in place" }));
-
- expect(screen.getByTestId("collection-scope")).toHaveTextContent("all");
- expect(screen.getByTestId("path")).toHaveTextContent("/memos/123");
- expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBeNull();
- });
-
- it("isolates the active selection across account changes", () => {
- const product = { name: "spaces/product", title: "Product", description: "" };
- const personal = { name: "spaces/personal", title: "Personal", description: "" };
- state.query.data = [product, personal];
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- sessionStorage.setItem(getSelectedSpaceStorageKey("users/bob"), personal.name);
- const view = renderProvider();
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name);
-
- state.currentUser = { name: "users/bob" };
- view.rerender(
-
-
-
-
- ,
- );
-
- expect(screen.getByTestId("selected-name")).toHaveTextContent(personal.name);
+ it.each(["/memos/123", "/archived", "/inbox", "/setting", "/u/alice"])("keeps %s global and switches to Space Home", (path) => {
+ setup(`${path}?filter=tagSearch%3Awork`);
+ expect(screen.getByTestId("space")).toHaveTextContent("all");
+ fireEvent.click(screen.getByText("B"));
+ expect(screen.getByTestId("path").textContent).toBe("/spaces/b");
});
});
diff --git a/web/tests/space-queries.test.tsx b/web/tests/space-queries.test.tsx
index 05bfc3bd..45279fb0 100644
--- a/web/tests/space-queries.test.tsx
+++ b/web/tests/space-queries.test.tsx
@@ -1,4 +1,5 @@
import { create } from "@bufbuild/protobuf";
+import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
@@ -12,6 +13,7 @@ import {
useDeleteSpace,
useDeleteSpaceInvitation,
useDeleteSpaceMember,
+ useSpace,
useSpaceInvitations,
useSpaceMembers,
useSpaces,
@@ -32,6 +34,7 @@ const clients = vi.hoisted(() => ({
listSpaceInvitations: vi.fn(),
listSpaceMembers: vi.fn(),
listSpaces: vi.fn(),
+ getSpace: vi.fn(),
listUserSpaceInvitations: vi.fn(),
updateSpace: vi.fn(),
updateSpaceMember: vi.fn(),
@@ -65,6 +68,35 @@ describe("Space queries", () => {
}
});
+ it("fetches the route Space with viewer-isolated cache entries", async () => {
+ clients.getSpace.mockResolvedValue(create(SpaceSchema, { name: SPACE_NAME, title: "Product" }));
+ const client = createQueryClient();
+ const view = renderHook(({ user }) => useSpace(user, SPACE_NAME), { initialProps: { user: VIEWER }, wrapper: createWrapper(client) });
+ await waitFor(() => expect(view.result.current.isSuccess).toBe(true));
+ expect(clients.getSpace).toHaveBeenCalledWith({ name: SPACE_NAME });
+ clients.getSpace.mockRejectedValue(new ConnectError("not found", Code.NotFound));
+ view.rerender({ user: OTHER_VIEWER });
+ expect(view.result.current.data).toBeUndefined();
+ await waitFor(() => expect(view.result.current.isError).toBe(true));
+ expect(clients.getSpace).toHaveBeenCalledTimes(2);
+ });
+
+ it("refetches an active Space after leaving and exposes the access failure", async () => {
+ const client = createQueryClient();
+ const member = create(SpaceMemberSchema, { name: `${SPACE_NAME}/members/test`, user: VIEWER });
+ client.setQueryData(spaceKeys.members(VIEWER, SPACE_NAME), [member]);
+ clients.getSpace.mockResolvedValue(create(SpaceSchema, { name: SPACE_NAME, title: "Product" }));
+ clients.deleteSpaceMember.mockResolvedValue({});
+ const view = renderHook(() => ({ space: useSpace(VIEWER, SPACE_NAME), leave: useDeleteSpaceMember(VIEWER) }), {
+ wrapper: createWrapper(client),
+ });
+ await waitFor(() => expect(view.result.current.space.isSuccess).toBe(true));
+ clients.getSpace.mockRejectedValue(new ConnectError("not found", Code.NotFound));
+ await act(() => view.result.current.leave.mutateAsync({ name: member.name }));
+ await waitFor(() => expect(view.result.current.space.isError).toBe(true));
+ expect(view.result.current.space.data).toBeUndefined();
+ });
+
it("loads every page so the switcher can show all available Spaces", async () => {
clients.listSpaces
.mockResolvedValueOnce({
@@ -224,7 +256,7 @@ describe("Space queries", () => {
});
expect(clients.deleteSpace).toHaveBeenCalledWith({ name: SPACE_NAME });
expect(queryClient.getQueryData(spaceKeys.list(VIEWER))).toEqual([createdSpace]);
- expect(queryClient.getQueryState(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined();
+ expect(queryClient.getQueryData(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined();
});
it("synchronizes admin invitation caches after create and delete", async () => {
@@ -374,6 +406,6 @@ describe("Space queries", () => {
});
expect(clients.deleteSpaceMember).toHaveBeenLastCalledWith({ name: viewerMember.name });
expect(queryClient.getQueryData(spaceKeys.list(VIEWER))).toEqual([]);
- expect(queryClient.getQueryState(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined();
+ expect(queryClient.getQueryData(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined();
});
});
diff --git a/web/tests/space-routes.test.tsx b/web/tests/space-routes.test.tsx
new file mode 100644
index 00000000..e6fab18a
--- /dev/null
+++ b/web/tests/space-routes.test.tsx
@@ -0,0 +1,131 @@
+import { Code, ConnectError } from "@connectrpc/connect";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { resolveQuickFindSubmission } from "@/components/AppSidebar/QuickFindDialog";
+import { getRouteActionPolicy, getSidebarRouteKind } from "@/components/AppSidebar/routes";
+import { CalendarHeader } from "@/components/CalendarView/CalendarHeader";
+import { resolveMemoDetailOrigin, resolveMemoOrigin } from "@/components/MemoView/navigation";
+import Calendar from "@/pages/Calendar";
+import { buildCollectionPath, getSpaceSwitchPath, resolveCollectionRoute } from "@/router/routes";
+import { SpaceRoute } from "@/router/SpaceRoute";
+
+const state = vi.hoisted(() => ({
+ selectedSpaceName: "spaces/a",
+ isSpaceReady: false,
+ spaceError: null as ConnectError | null,
+ retrySpace: vi.fn(),
+}));
+vi.mock("@/contexts/SpaceContext", () => ({ useSpaceContext: () => state }));
+vi.mock("@/utils/i18n", async (original) => ({
+ ...(await original()),
+ useTranslate: () => (key: string) => key,
+}));
+vi.mock("@/components/CalendarView/CalendarView", () => ({ CalendarView: () => calendar content
}));
+
+beforeEach(() => {
+ state.isSpaceReady = false;
+ state.spaceError = null;
+ state.retrySpace.mockClear();
+});
+describe("Space route contract", () => {
+ it.each([
+ ["/spaces/a", "home"],
+ ["/spaces/a/explore", "explore"],
+ ["/spaces/a/calendar/2026/09/06", "calendar"],
+ ["/spaces/a/attachments", "attachments"],
+ ["/spaces/a/archived", "common"],
+ ["/spaces/a/unknown", "common"],
+ ])("classifies %s as %s", (path, kind) => expect(getSidebarRouteKind(path)).toBe(kind));
+ it("does not interpret unknown space routes or encoded slashes as global collections", () => {
+ expect(resolveCollectionRoute("/spaces/a/unknown").isCollection).toBe(false);
+ expect(resolveCollectionRoute("/spaces/a/archived").spaceName).toBeUndefined();
+ expect(resolveCollectionRoute("/spaces/a%2Fb").isCollection).toBe(false);
+ expect(resolveCollectionRoute("/spaces/A").spaceName).toBe("spaces/A");
+ expect(buildCollectionPath("/calendar/2026/09", "spaces/A")).toBe("/spaces/A/calendar/2026/09");
+ });
+ it("preserves collection parameters but drops unrelated global page parameters on switching", () => {
+ expect(getSpaceSwitchPath({ pathname: "/spaces/a/explore", search: "?filter=tagSearch%3Ax" }, "spaces/b")).toBe(
+ "/spaces/b/explore?filter=tagSearch%3Ax",
+ );
+ expect(getSpaceSwitchPath({ pathname: "/setting", search: "?section=spaces" }, "spaces/b")).toBe("/spaces/b");
+ });
+ it("searches from calendar and attachments into the same Space Home", () => {
+ for (const path of ["/spaces/a/calendar/2026/09", "/spaces/a/attachments"]) {
+ expect(resolveQuickFindSubmission(path, "roadmap", [], "text").destination).toBe("/spaces/a?filter=contentSearch%3Aroadmap");
+ }
+ expect(getRouteActionPolicy("/memos/a").searchDestination).toBe("/");
+ });
+ it("carries the full calendar origin through a permanent memo link", () => {
+ const parentPage = "/spaces/a/calendar/2026/09/06?filter=tagSearch%3Awork";
+ expect(
+ resolveMemoOrigin({ pathname: "/spaces/a/calendar/2026/09/06", search: "?filter=tagSearch%3Awork", memoName: "memos/x" }),
+ ).toEqual({ parentPage, parentScope: "preserve" });
+ expect(resolveMemoDetailOrigin({ from: parentPage })).toEqual({ parentPage, parentScope: "preserve" });
+ expect(resolveMemoDetailOrigin(undefined)).toEqual({ parentPage: "/", parentScope: "all" });
+ });
+});
+
+describe("Space page availability", () => {
+ const setup = () =>
+ render(
+
+
+ }>
+ space content
} />
+
+
+ ,
+ );
+ it("withholds content while loading", () => {
+ setup();
+ expect(screen.queryByText("space content")).toBeNull();
+ expect(screen.getByRole("status")).toHaveTextContent("space.loading");
+ });
+ it("renders content only after membership resolves", () => {
+ state.isSpaceReady = true;
+ setup();
+ expect(screen.getByText("space content")).toBeInTheDocument();
+ });
+ it.each([Code.NotFound, Code.PermissionDenied])("shows the same unavailable state for code %s", (code) => {
+ state.spaceError = new ConnectError("unavailable", code);
+ setup();
+ expect(screen.getByRole("status")).toHaveTextContent("space.unavailable");
+ expect(screen.getByRole("link", { name: "space.back-to-memos" })).toHaveAttribute("href", "/");
+ expect(screen.queryByText("space content")).toBeNull();
+ });
+ it("offers retry for a transient failure", () => {
+ state.spaceError = new ConnectError("offline", Code.Unavailable);
+ setup();
+ fireEvent.click(screen.getByRole("button", { name: "search.retry" }));
+ expect(state.retrySpace).toHaveBeenCalledOnce();
+ });
+});
+
+describe("Space calendar navigation", () => {
+ it("keeps Space and filters in month links", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("link", { name: "common.next-month" })).toHaveAttribute(
+ "href",
+ "/spaces/a/calendar/2026/10?filter=tagSearch%3Awork",
+ );
+ expect(screen.getByRole("link", { name: "common.previous-month" })).toHaveAttribute(
+ "href",
+ "/spaces/a/calendar/2026/08?filter=tagSearch%3Awork",
+ );
+ });
+ it("resolves an incomplete Space calendar address inside the Space", async () => {
+ render(
+
+
+ } />
+
+ ,
+ );
+ expect(await screen.findByText("calendar content")).toBeInTheDocument();
+ });
+});
diff --git a/web/tests/space-switcher.test.tsx b/web/tests/space-switcher.test.tsx
index 7aa32cfd..8f3d7a19 100644
--- a/web/tests/space-switcher.test.tsx
+++ b/web/tests/space-switcher.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { createEvent, fireEvent, screen, render as testingRender, waitFor, within } from "@testing-library/react";
+import { createMemoryRouter, MemoryRouter, RouterProvider } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpaceSwitcher from "@/components/AppSidebar/SpaceSwitcher";
@@ -9,7 +10,6 @@ const spaceState = vi.hoisted(() => ({
],
selectedSpace: undefined as { name: string; title: string; description: string } | undefined,
selectedSpaceName: undefined as string | undefined,
- selectMemos: vi.fn(),
selectSpace: vi.fn(),
}));
@@ -49,6 +49,8 @@ vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
+const render = (ui: React.ReactNode) => testingRender({ui});
+
describe("SpaceSwitcher", () => {
beforeEach(() => {
spaceState.spaces = [
@@ -57,10 +59,29 @@ describe("SpaceSwitcher", () => {
];
spaceState.selectedSpace = undefined;
spaceState.selectedSpaceName = undefined;
- spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
});
+ it.each([
+ "/",
+ "/setting",
+ "/memos/direct",
+ ])("does not navigate the current scope from %s and leaves modified clicks native", async (path) => {
+ const router = createMemoryRouter([{ path: "*", element: }], { initialEntries: [path] });
+ testingRender();
+ const key = router.state.location.key;
+ fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "Memos" }));
+ expect(router.state.location.key).toBe(key);
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
+ fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
+ const product = await screen.findByRole("menuitemradio", { name: "Product" });
+ const click = createEvent.click(product, { ctrlKey: true, cancelable: true });
+ fireEvent(product, click);
+ expect(click.defaultPrevented).toBe(false);
+ expect(router.state.location.pathname).toBe(path);
+ });
+
it("lists Memos, every available Space, and the create entry", async () => {
render();
@@ -205,18 +226,18 @@ describe("SpaceSwitcher", () => {
expect(researchRow).not.toHaveTextContent("research-space");
});
- it("switches context without navigation and opens Space creation", async () => {
+ it("links to spaces and opens Space creation", async () => {
render();
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
- fireEvent.click(await screen.findByRole("menuitemradio", { name: "Product" }));
- expect(spaceState.selectSpace).toHaveBeenCalledWith(spaceState.spaces[0]);
+ const product = await screen.findByRole("menuitemradio", { name: "Product" });
+ expect(product).toHaveAttribute("href", "/spaces/product");
+ fireEvent.click(product);
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
// Selecting Memos is how a signed-in user gets back to the home feed.
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Memos" }));
- expect(spaceState.selectMemos).toHaveBeenCalledOnce();
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));