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.
This commit is contained in:
johnnyjoygh 2026-09-07 09:29:50 +08:00
parent 0716eaac2c
commit af008b2153
45 changed files with 704 additions and 686 deletions

View file

@ -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 <SpaceSwitcher className={className} size={size} />;
}

View file

@ -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<QuickFindMode>("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);

View file

@ -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
</span>
);
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 }) => (
<DropdownMenuItem
role="menuitemradio"
aria-checked={selected}
aria-label={ariaLabel}
title={ariaLabel}
closeOnClick
onClick={onSelect}
render={
<Link
to={to}
onClick={(event) => {
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<number>();
const triggerRef = useRef<HTMLButtonElement>(null);
@ -141,7 +141,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
style={menuWidth ? { width: `${menuWidth}px` } : undefined}
>
<DropdownMenuGroup>
<ContextItem selected={!selectedSpaceName} onSelect={selectMemos}>
<ContextItem selected={!selectedSpaceName} to={memosPath}>
<span className="min-w-0 flex-1">
<MemosLogo compact size="sm" />
</span>
@ -157,7 +157,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
<ContextItem
key={space.name}
selected={space.name === selectedSpaceName}
onSelect={() => selectSpace(space)}
to={getSpaceSwitchPath(location, space.name)}
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
>
<SpaceMark size="sm" />

View file

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

View file

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

View file

@ -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 (
<Link
to={`/${memoName}`}
// Attachments is collection-scoped, but Memo filters render on Home.
// Carry a Home origin so direct-resource handling preserves this scope.
state={createMemoNavigationState(ROUTES.HOME, "preserve")}
state={createMemoNavigationState(`${location.pathname}${location.search}`, "preserve")}
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
>
<span className="truncate">{t("attachment-library.labels.memo")}</span>

View file

@ -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")}
</button>

View file

@ -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<LinkProps, "to"> {
/** Pathname only; the current search string rides along so view and tag filters survive. */
@ -12,8 +13,8 @@ export interface CalendarLinkProps extends Omit<LinkProps, "to"> {
* pathname change.
*/
export const CalendarLink = forwardRef<HTMLAnchorElement, CalendarLinkProps>(({ to, ...props }, ref) => {
const { search } = useLocation();
return <Link ref={ref} to={{ pathname: to, search }} {...props} />;
const { pathname, search } = useLocation();
return <Link ref={ref} to={{ pathname: collectionPathForLocation(to, pathname), search }} {...props} />;
});
CalendarLink.displayName = "CalendarLink";

View file

@ -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));

View file

@ -56,7 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
parentScope: props.parentScope,
parentPage: props.parentPage,
onEdit: props.onEdit,
setDeleteDialogOpen,
});

View file

@ -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,

View file

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

View file

@ -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<HTMLSpanElement> {
node?: Element; // AST node from react-markdown
@ -19,10 +18,9 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
}
export const Tag: React.FC<TagProps> = ({ "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<TagProps> = ({ "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;
}

View file

@ -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 = ({
<Link
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
to={parentPage}
onClick={() => {
if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) {
clearSelectedSpace();
}
}}
viewTransition
>
<SidebarRowIconSlot icon={ArrowLeftIcon} />

View file

@ -130,7 +130,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
</TooltipProvider>
)}
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
<MemoActionMenu memo={memo} parentPage={parentPage} readonly={readonly} onEdit={openEditor} />
</div>
</div>
);

View file

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

View file

@ -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

View file

@ -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<MemoFilterContextValue | null>(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]);

View file

@ -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<string, Space>;
@ -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<SpaceContextValue | null>(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<string, Space> = new Map();
const NO_DUPLICATE_SPACE_TITLES: ReadonlySet<string> = 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<Space>();
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<CollectionScope>(
() => (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<SpaceContextValue>(
() => ({
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 <SpaceContext.Provider value={value}>{children}</SpaceContext.Provider>;
}
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 <SpaceContext.Provider value={anonymousValue}>{children}</SpaceContext.Provider>;
}
return (
<UserSpaceSession key={currentUserName} userName={currentUserName}>
{children}
</UserSpaceSession>
);
}
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;
}

View file

@ -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 = <T>(queryClient: QueryClient, queryKey: QueryKey, updat
const removeSpaceFromViewerCache = (queryClient: QueryClient, viewerName: string, spaceName: string) => {
updateCachedList<Space>(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<Space, ConnectError>({
queryKey: spaceKeys.space(viewerName ?? "", spaceName ?? ""),
queryFn: () => spaceServiceClient.getSpace({ name: spaceName! }),
enabled: queryEnabled(viewerName, spaceName),
initialData: () => queryClient.getQueryData<Space[]>(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<Space[]>(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<Space>(queryClient, spaceKeys.list(viewerName), (spaces) => upsertByName(spaces, space));
},
});
@ -343,7 +363,8 @@ export function useUpdateSpaceMember(viewerName: string) {
if (spaceName) {
updateCachedList<SpaceMember>(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 });
}
},

View file

@ -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<MemoScope, "archived">;
@ -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";

View file

@ -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ı",

View file

@ -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",

View file

@ -966,5 +966,9 @@
"description": "这些文件从未附加到任何备忘。可在此查看或删除。",
"title": "未关联的上传"
}
},
"space": {
"unavailable": "空间不存在或你没有访问权限。",
"back-to-memos": "返回 Memos"
}
}

View file

@ -1046,6 +1046,8 @@
}
},
"space": {
"unavailable": "空間不存在或你沒有存取權限。",
"back-to-memos": "返回 Memos",
"create": "建立空間",
"create-description": "建立供備忘錄與成員共用的空間。",
"create-success": "已建立空間",

View file

@ -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<CalendarRouteParams>());
if (!state) {
return <Navigate to={buildCalendarPath(getCurrentMonth())} replace />;
return (
<Navigate
to={{ pathname: collectionPathForLocation(buildCalendarPath(getCurrentMonth()), location.pathname), search: location.search }}
replace
/>
);
}
return <CalendarView month={state.month} date={state.date} />;
};

View file

@ -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 <NotFound />;
if (isSpaceReady) return <Outlet />;
const unavailable = hasConnectCode(spaceError, Code.NotFound, Code.PermissionDenied);
return (
<section className="flex min-h-[50vh] w-full flex-col items-center justify-center gap-4 px-4 text-center" role="status">
{!spaceError ? (
<>
<LoaderCircleIcon className="size-5 animate-spin text-muted-foreground" />
<p>{t("space.loading")}</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">{t(unavailable ? "space.unavailable" : "space.load-error")}</p>
<div className="flex items-center gap-2">
{!unavailable && (
<Button variant="outline" onClick={retrySpace}>
{t("search.retry")}
</Button>
)}
<Link className={buttonVariants({ variant: "outline" })} to={ROUTES.HOME}>
{t("space.back-to-memos")}
</Link>
</div>
</>
)}
</section>
);
};

View file

@ -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: <RequireFullInitializationRoute />,
children: [
{
path: SPACE_ROUTE_PATTERN,
children: [
{
element: <SpaceRoute />,
children: [
{
element: <MainLayout />,
children: [
{ index: true, element: <Home /> },
{ path: "explore", element: <Explore /> },
{ path: "calendar/:year?/:month?/:day?", element: <Calendar /> },
],
},
{ path: "attachments", element: <Attachments /> },
],
},
{ path: "*", element: <NotFound /> },
],
},
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },

View file

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

View file

@ -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(
<MemoryRouter initialEntries={[path]}>
@ -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(
<MemoryRouter initialEntries={[path]}>
@ -315,19 +308,14 @@ describe("App sidebar logo", () => {
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/explore"]}>
<MemoryRouter initialEntries={["/spaces/product/explore"]}>
<AppSidebar />
</MemoryRouter>,
);
@ -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(
<MemoryRouter initialEntries={["/u/alice"]}>
<AppSidebar />
@ -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(
<MemoryRouter initialEntries={["/archived"]}>
<AppSidebar />
@ -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(
<MemoryRouter initialEntries={["/attachments"]}>
<MemoryRouter initialEntries={["/spaces/product/attachments"]}>
<AppSidebar />
</MemoryRouter>,
);
@ -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();
});

View file

@ -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",
});
});
});

View file

@ -52,7 +52,7 @@ describe("<AttachmentMediaGrid>", () => {
const onPreview = vi.fn();
render(
<MemoryRouter initialEntries={["/attachments"]}>
<MemoryRouter initialEntries={["/spaces/product/attachments?filter=tagSearch%3Awork"]}>
<AttachmentMediaGrid groups={groups} onPreview={onPreview} />
<LocationStateProbe />
</MemoryRouter>,
@ -67,7 +67,9 @@ describe("<AttachmentMediaGrid>", () => {
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", () => {

View file

@ -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(
<GlobalMemoEditorProvider>
@ -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(<Trigger />);
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({

View file

@ -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(<MemoActionMenu memo={memo} parentScope="preserve" />);
render(<MemoActionMenu memo={memo} />);
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");

View file

@ -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();

View file

@ -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: () => <div data-testid="share-panel" /> }));
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", () => {

View file

@ -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: (
<MemoFilterProvider>
<Harness />
</MemoFilterProvider>
),
},
],
{ initialEntries: [origin] },
);
render(<RouterProvider router={router} />);
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" },

View file

@ -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<typeof import("@/contexts/MemoFilterContext")>();
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(
<MemoryRouter initialEntries={["/memos/parent"]}>
<Tag data-tag="work">#work</Tag>
@ -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");
});
});

View file

@ -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");

View file

@ -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,
});
});

View file

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

View file

@ -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 (
<div>
<output data-testid="selected-name">{selectedSpaceName ?? "Memos"}</output>
<output data-testid="selected-title">{selectedSpace?.title ?? ""}</output>
<output data-testid="collection-scope">
{collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind}
</output>
<output data-testid="memo-filter">{memoFilter ?? "all"}</output>
<output data-testid="duplicate-titles">{[...duplicateSpaceTitles].join(",")}</output>
<button type="button" onClick={selectMemos}>
Select Memos
</button>
<button type="button" onClick={clearSelectedSpace}>
Clear Space in place
</button>
<button type="button" onClick={() => spaces[0] && selectSpace(spaces[0])}>
Select first Space
</button>
<button type="button" onClick={() => selectSpace(newlyCreatedSpace)}>
Select new Space
</button>
<CurrentPath />
</div>
);
};
const CurrentPath = () => {
const location = useLocation();
return <output data-testid="path">{`${location.pathname}${location.search}`}</output>;
};
const renderProvider = (initialPath = "/explore") =>
render(
<MemoryRouter initialEntries={[initialPath]}>
<SpaceProvider>
<Probe />
</SpaceProvider>
</MemoryRouter>,
const context = useSpaceContext();
return (
<>
<output data-testid="path">{location.pathname + location.search}</output>
<output data-testid="space">{context.selectedSpaceName || "all"}</output>
<output data-testid="filter">{context.memoFilter || "all"}</output>
<output data-testid="ready">{String(context.isSpaceReady)}</output>
<output data-testid="duplicates">{[...context.duplicateSpaceTitles].join(",")}</output>
<Link to={getSpaceSwitchPath(location, "spaces/b")}>B</Link>
<Link to={getSpaceSwitchPath(location)}>Memos</Link>
<Outlet />
</>
);
describe("SpaceProvider", () => {
};
const setup = (path = "/") => {
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<Probe />
</SpaceProvider>
),
},
],
{ initialEntries: [path] },
);
render(<RouterProvider router={router} />);
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(
<MemoryRouter initialEntries={["/explore"]}>
<SpaceProvider>
<Probe />
</SpaceProvider>
</MemoryRouter>,
);
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");
});
});

View file

@ -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();
});
});

View file

@ -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<typeof import("@/utils/i18n")>()),
useTranslate: () => (key: string) => key,
}));
vi.mock("@/components/CalendarView/CalendarView", () => ({ CalendarView: () => <p>calendar content</p> }));
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(
<MemoryRouter initialEntries={["/spaces/a"]}>
<Routes>
<Route element={<SpaceRoute />}>
<Route path="/spaces/a" element={<p>space content</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/spaces/a/calendar/2026/09?filter=tagSearch%3Awork"]}>
<CalendarHeader month="2026-09" monthLabel="September 2026" today="2026-09-06" closable />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/spaces/a/calendar?filter=tagSearch%3Awork"]}>
<Routes>
<Route path="/spaces/:spaceUid/calendar/:year?/:month?/:day?" element={<Calendar />} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText("calendar content")).toBeInTheDocument();
});
});

View file

@ -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(<MemoryRouter>{ui}</MemoryRouter>);
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: <SpaceSwitcher /> }], { initialEntries: [path] });
testingRender(<RouterProvider router={router} />);
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(<SpaceSwitcher />);
@ -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(<SpaceSwitcher />);
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" }));