perf(web): reduce memo feed startup and rendering work

This commit is contained in:
johnnyjoygh
2026-07-20 00:15:26 +08:00
parent 7c78320999
commit 249b331596
36 changed files with 816 additions and 158 deletions
@@ -27,7 +27,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { State } from "@/types/proto/api/v1/common_pb";
import { useTranslate } from "@/utils/i18n";
import { countTasks } from "@/utils/markdown-manipulation";
import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types";
@@ -41,10 +40,8 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
// Derived state
const isComment = Boolean(memo.parent);
const isArchived = memo.state === State.ARCHIVED;
const taskStats = countTasks(memo.content);
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
const hasOpenTasks = taskStats.completed < taskStats.total;
const hasCompletedTasks = taskStats.completed > 0;
const canMutateTasks = !readonly && !isArchived && Boolean(memo.property?.hasTaskList);
const hasOpenTasks = Boolean(memo.property?.hasIncompleteTasks);
// Action handlers
const {
@@ -117,7 +114,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<CheckCheckIcon className="w-4 h-auto" />
{t("memo.task-actions.check-all")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
<DropdownMenuItem onClick={handleUncheckAllTaskListItemsClick}>
<ListRestartIcon className="w-4 h-auto" />
{t("memo.task-actions.uncheck-all")}
</DropdownMenuItem>
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
interface LinkMetadataCardProps {
url: string;
fallback: React.ReactNode;
enabled?: boolean;
}
function getHostname(url: string): string {
@@ -16,9 +17,9 @@ function getHostname(url: string): string {
}
}
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
const LinkMetadataCard = ({ url, fallback, enabled = true }: LinkMetadataCardProps) => {
const [imageFailed, setImageFailed] = useState(false);
const { data: metadata, isSuccess } = useLinkMetadata(url);
const { data: metadata, isSuccess } = useLinkMetadata(url, { enabled });
const title = metadata?.title.trim() ?? "";
const description = metadata?.description.trim() ?? "";
@@ -1,5 +1,5 @@
import type { Element } from "hast";
import { type ComponentProps, type ReactNode, Suspense } from "react";
import { type ComponentProps, memo, type ReactNode, Suspense } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
@@ -179,7 +179,7 @@ export const MemoMarkdownRendererCore = ({
);
};
export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
const MemoMarkdownRendererComponent = (props: MemoMarkdownRendererProps) => {
if (!hasMathSyntax(props.content)) {
return <MemoMarkdownRendererCore {...props} />;
}
@@ -190,3 +190,18 @@ export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
</Suspense>
);
};
const haveEqualResolvedMentions = (left: Set<string>, right: Set<string>) => {
if (left === right) return true;
if (left.size !== right.size) return false;
return Array.from(left).every((username) => right.has(username));
};
export const MemoMarkdownRenderer = memo(
MemoMarkdownRendererComponent,
(previous, next) =>
previous.content === next.content &&
previous.memoName === next.memoName &&
previous.compact === next.compact &&
haveEqualResolvedMentions(previous.resolvedMentionUsernames, next.resolvedMentionUsernames),
);
@@ -1,41 +1,105 @@
import { useQueries } from "@tanstack/react-query";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { userDetailQueryOptions, useUsersByUsernames } from "@/hooks/useUserQueries";
import { extractUsernameFromName } from "@/lib/resource-names";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
const MentionResolutionContext = createContext<Set<string> | null>(null);
interface UserResolutionContextValue {
mentionUsernamesByContent: ReadonlyMap<string, string[]>;
requestedUserNames: ReadonlySet<string>;
resolvedMentionUsernames: ReadonlySet<string>;
usersByName: ReadonlyMap<string, User | undefined>;
}
const UserResolutionContext = createContext<UserResolutionContextValue | null>(null);
const EMPTY_USER_NAMES: string[] = [];
interface MentionResolutionProviderProps {
contents: string[];
userNames?: string[];
children: ReactNode;
}
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
const resolvedMentionUsernames = useMemo(() => {
if (!mentionUsers) {
return new Set<string>();
export const MentionResolutionProvider = ({ contents, userNames = EMPTY_USER_NAMES, children }: MentionResolutionProviderProps) => {
const mentionUsernamesByContent = useMemo(
() => new Map(contents.map((content) => [content, extractMentionUsernames(content)] as const)),
[contents],
);
const mentionUsernames = useMemo(
() => Array.from(new Set(Array.from(mentionUsernamesByContent.values()).flat())),
[mentionUsernamesByContent],
);
const requestedUserNames = useMemo(() => new Set(userNames.filter(Boolean)), [userNames]);
const requestedUsernames = useMemo(
() => Array.from(new Set([...mentionUsernames, ...Array.from(requestedUserNames, extractUsernameFromName)])),
[mentionUsernames, requestedUserNames],
);
const { data: usersByUsername } = useUsersByUsernames(requestedUsernames);
const value = useMemo<UserResolutionContextValue>(() => {
const resolvedMentionUsernames = new Set<string>();
const usersByName = new Map<string, User | undefined>();
for (const [username, user] of usersByUsername ?? []) {
if (user) {
usersByName.set(user.name, user);
if (mentionUsernames.includes(username)) {
resolvedMentionUsernames.add(username);
}
}
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [mentionUsers]);
return { mentionUsernamesByContent, requestedUserNames, resolvedMentionUsernames, usersByName };
}, [mentionUsernames, mentionUsernamesByContent, requestedUserNames, usersByUsername]);
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
return <UserResolutionContext.Provider value={value}>{children}</UserResolutionContext.Provider>;
};
export function useResolvedMentionUsernames(usernames: string[]) {
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
export function useResolvedMentionUsernames(content: string) {
const sharedResolution = useContext(UserResolutionContext);
const shouldUseSharedResolution = sharedResolution !== null;
const usernames = useMemo(
() => sharedResolution?.mentionUsernamesByContent.get(content) ?? extractMentionUsernames(content),
[content, sharedResolution],
);
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
return useMemo(() => {
if (sharedResolvedMentionUsernames) {
return sharedResolvedMentionUsernames;
if (sharedResolution) {
return new Set(usernames.filter((username) => sharedResolution.resolvedMentionUsernames.has(username)));
}
if (!mentionUsers) {
return new Set<string>();
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [sharedResolvedMentionUsernames, mentionUsers]);
}, [mentionUsers, sharedResolution, usernames]);
}
export function useResolvedUser(name: string, options?: { enabled?: boolean }) {
const sharedResolution = useContext(UserResolutionContext);
const enabled = options?.enabled ?? true;
const useSharedResolution = enabled && Boolean(sharedResolution?.requestedUserNames.has(name));
const fallbackQueryOptions: ReturnType<typeof userDetailQueryOptions>[] =
enabled && !useSharedResolution ? [userDetailQueryOptions(name)] : [];
const fallbackQueries = useQueries({ queries: fallbackQueryOptions });
return useSharedResolution ? sharedResolution?.usersByName.get(name) : fallbackQueries[0]?.data;
}
export function useResolvedUsersByNames(names: string[]) {
const sharedResolution = useContext(UserResolutionContext);
const useSharedResolution = Boolean(sharedResolution && names.every((name) => sharedResolution.requestedUserNames.has(name)));
const uniqueNames = useMemo(() => Array.from(new Set(names)), [names]);
const fallbackQueries = useQueries({
queries: useSharedResolution ? [] : uniqueNames.map((name) => userDetailQueryOptions(name)),
});
return useMemo(() => {
if (useSharedResolution && sharedResolution) {
return new Map(names.map((name) => [name, sharedResolution.usersByName.get(name)] as const));
}
return new Map(uniqueNames.map((name, index) => [name, fallbackQueries[index]?.data] as const));
}, [fallbackQueries, names, sharedResolution, uniqueNames, useSharedResolution]);
}
+2 -4
View File
@@ -1,6 +1,5 @@
import { memo, useMemo } from "react";
import { memo } from "react";
import { cn } from "@/lib/utils";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
import type { MemoContentProps } from "./types";
@@ -11,8 +10,7 @@ import type { MemoContentProps } from "./types";
// since a collapsed card may hide the target).
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
const resolvedMentionUsernames = useResolvedMentionUsernames(content);
return (
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
@@ -1,5 +1,6 @@
import type { Element } from "hast";
import { useLinkPreviewEnabled } from "@/contexts/ViewContext";
import { useNearViewport } from "@/hooks/useNearViewport";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import LinkMetadataCard from "../LinkMetadataCard";
@@ -10,6 +11,11 @@ interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, Rea
children: React.ReactNode;
}
interface DeferredLinkPreviewProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
href: string;
}
export function getSingleLinkHref(node?: Element): string | undefined {
if (!node || node.tagName !== "p") {
return undefined;
@@ -45,19 +51,33 @@ export function getSingleLinkHref(node?: Element): string | undefined {
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
}
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
const { blockDepth } = useMarkdownRenderContext();
const linkPreviewEnabled = useLinkPreviewEnabled();
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
const paragraph = (
<p className={cn(markdownStyles.paragraph, className)} {...props}>
const DeferredLinkPreview = ({ children, className, href, ...props }: DeferredLinkPreviewProps) => {
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLParagraphElement>();
const fallback = (
<p ref={viewportRef} className={cn(markdownStyles.paragraph, className)} {...props}>
{children}
</p>
);
return <LinkMetadataCard url={href} fallback={fallback} enabled={isNearViewport} />;
};
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
const { blockDepth } = useMarkdownRenderContext();
const linkPreviewEnabled = useLinkPreviewEnabled();
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
if (href) {
return <LinkMetadataCard url={href} fallback={paragraph} />;
return (
<DeferredLinkPreview href={href} className={className} {...props}>
{children}
</DeferredLinkPreview>
);
}
return paragraph;
return (
<p className={cn(markdownStyles.paragraph, className)} {...props}>
{children}
</p>
);
};
@@ -11,19 +11,27 @@ interface Props {
features?: MemoExplorerFeatures;
statisticsData: StatisticsData;
tagCount: Record<string, number>;
onOpenChange?: (open: boolean) => void;
}
const MemoExplorerDrawer = (props: Props) => {
const { context, features, statisticsData, tagCount } = props;
const { context, features, statisticsData, tagCount, onOpenChange } = props;
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
onOpenChange?.(false);
}, [location.pathname, onOpenChange]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<Sheet
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
}}
>
<SheetTrigger render={<Button variant="ghost" />}>
<MenuIcon className="size-5 text-foreground" />
</SheetTrigger>
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import type { PropsWithChildren } from "react";
import type { PropsWithChildren, Ref } from "react";
import { cn } from "@/lib/utils";
import SectionHeader, { type SectionHeaderTab } from "./SectionHeader";
@@ -10,11 +10,12 @@ interface MetadataSectionProps extends PropsWithChildren {
tabs?: SectionHeaderTab[];
className?: string;
contentClassName?: string;
rootRef?: Ref<HTMLDivElement>;
}
const MetadataSection = ({ icon, title, count, tabs, className, contentClassName, children }: MetadataSectionProps) => {
const MetadataSection = ({ icon, title, count, tabs, className, contentClassName, rootRef, children }: MetadataSectionProps) => {
return (
<div className={cn("w-full overflow-hidden rounded-lg border border-border bg-muted/20", className)}>
<div ref={rootRef} className={cn("w-full overflow-hidden rounded-lg border border-border bg-muted/20", className)}>
<SectionHeader icon={icon} title={title} count={count} tabs={tabs} />
<div className={contentClassName}>{children}</div>
</div>
@@ -1,6 +1,7 @@
import { LinkIcon, MilestoneIcon } from "lucide-react";
import { useMemo, useState } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import { useNearViewport } from "@/hooks/useNearViewport";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import RelationCard from "./RelationCard";
@@ -17,16 +18,13 @@ interface RelationListViewProps {
function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
const { referencing: referencingRelations, referenced: referencedRelations } = useMemo(
() => getRelationBuckets(relations, currentMemoName),
[relations, currentMemoName],
);
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
return null;
}
const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0;
const direction: RelationDirection = hasBothTabs ? activeTab : referencingRelations.length > 0 ? "referencing" : "referenced";
const isReferencing = direction === "referencing";
@@ -40,10 +38,15 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
}),
[activeRelations, direction],
);
const resolvedMemos = useResolvedRelationMemos(activeMemoNames);
const resolvedMemos = useResolvedRelationMemos(activeMemoNames, { enabled: isNearViewport });
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
return null;
}
return (
<MetadataSection
rootRef={viewportRef}
className={className}
icon={icon}
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
@@ -4,16 +4,17 @@ import { useEffect, useMemo, useState } from "react";
import { findMemoInCollectionQueries, memoDetailQueryOptions } from "@/hooks/useMemoQueries";
import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
export const useResolvedRelationMemos = (memoNames: string[]) => {
export const useResolvedRelationMemos = (memoNames: string[], options?: { enabled?: boolean }) => {
const queryClient = useQueryClient();
const [resolvedMemos, setResolvedMemos] = useState<Record<string, MemoRelation_Memo>>({});
const enabled = options?.enabled ?? true;
const missingMemoNames = useMemo(() => {
return Array.from(new Set(memoNames)).filter((name) => name && !resolvedMemos[name]);
}, [memoNames, resolvedMemos]);
useEffect(() => {
if (missingMemoNames.length === 0) {
if (!enabled || missingMemoNames.length === 0) {
return;
}
@@ -47,7 +48,7 @@ export const useResolvedRelationMemos = (memoNames: string[]) => {
return () => {
cancelled = true;
};
}, [missingMemoNames, queryClient]);
}, [enabled, missingMemoNames, queryClient]);
return resolvedMemos;
};
@@ -1,9 +1,9 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useResolvedUsersByNames } from "@/components/MemoContent/MentionResolutionContext";
import { memoServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { useUsersByNames } from "@/hooks/useUserQueries";
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
@@ -11,7 +11,7 @@ export type ReactionGroup = Map<string, User[]>;
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
const creatorNames = useMemo(() => reactions.map((r) => r.creator), [reactions]);
const { data: userMap } = useUsersByNames(creatorNames);
const userMap = useResolvedUsersByNames(creatorNames);
return useMemo(() => {
const reactionGroup = new Map<string, User[]>();
+11 -5
View File
@@ -1,10 +1,10 @@
import { type ComponentType, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ComponentType, memo, Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useResolvedUser } from "@/components/MemoContent/MentionResolutionContext";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
@@ -28,7 +28,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const currentUser = useCurrentUser();
const { userTagsSetting } = useAuth();
const creator = useUser(memoData.creator).data;
const creator = useResolvedUser(memoData.creator, { enabled: Boolean(showCreator || props.shareImageDialogOpen) });
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const parentPage = parentPageProp || "/";
@@ -54,7 +54,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
useEffect(() => {
// The card width is only needed by the share-image dialog. Keep feed cards
// free of a permanent ResizeObserver and measure only while that dialog is open.
useLayoutEffect(() => {
if (!props.shareImageDialogOpen) {
return;
}
const card = cardRef.current;
if (!card) {
return;
@@ -79,7 +85,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
resizeObserver.observe(card);
return () => resizeObserver.disconnect();
}, []);
}, [props.shareImageDialogOpen]);
const contextValue = useMemo(
() => ({
@@ -2,6 +2,7 @@ import { ArrowUpRightIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { MemoPreview } from "@/components/MemoPreview";
import { useMemoComments } from "@/hooks/useMemoQueries";
import { useNearViewport } from "@/hooks/useNearViewport";
import { useUsersByNames } from "@/hooks/useUserQueries";
import { extractMemoIdFromName } from "@/lib/resource-names";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
@@ -9,8 +10,12 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
const MemoCommentListView: React.FC = () => {
const { memo } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0, pageSize: 3 });
const { data } = useMemoComments(memo.name, {
enabled: isNearViewport && !isInMemoDetailPage && commentAmount > 0,
pageSize: 3,
});
const comments = data?.memos ?? [];
const displayedComments = comments.slice(0, 3);
const { data: commentCreators } = useUsersByNames(displayedComments.map((comment) => comment.creator));
@@ -20,7 +25,7 @@ const MemoCommentListView: React.FC = () => {
}
return (
<div className="border border-t-0 border-border rounded-b-lg px-4 pt-2 pb-3 flex flex-col gap-1">
<div ref={viewportRef} className="border border-t-0 border-border rounded-b-lg px-4 pt-2 pb-3 flex flex-col gap-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
<Link
@@ -2,6 +2,8 @@ import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
@@ -46,17 +48,21 @@ interface Props {
}
function useAutoFetchWhenNotScrollable({
enabled,
hasNextPage,
isFetchingNextPage,
memoCount,
onFetchNext,
}: {
enabled: boolean;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
memoCount: number;
onFetchNext: () => Promise<unknown>;
}) {
const autoFetchTimeoutRef = useRef<number | null>(null);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const isPageScrollable = useCallback(() => {
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
@@ -70,22 +76,31 @@ function useAutoFetchWhenNotScrollable({
await new Promise((resolve) => setTimeout(resolve, 200));
const shouldFetch = !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
const shouldFetch = enabledRef.current && !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
if (shouldFetch) {
await onFetchNext();
autoFetchTimeoutRef.current = window.setTimeout(() => {
void checkAndFetchIfNeeded();
}, 500);
if (enabledRef.current) {
autoFetchTimeoutRef.current = window.setTimeout(() => {
void checkAndFetchIfNeeded();
}, 500);
}
}
}, [hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
}, [enabled, hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
useEffect(() => {
if (!isFetchingNextPage && memoCount > 0) {
if (enabled && !isFetchingNextPage && memoCount > 0) {
void checkAndFetchIfNeeded();
}
}, [memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
}, [enabled, memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
useEffect(() => {
if (!enabled && autoFetchTimeoutRef.current) {
clearTimeout(autoFetchTimeoutRef.current);
autoFetchTimeoutRef.current = null;
}
}, [enabled]);
useEffect(() => {
return () => {
@@ -98,6 +113,8 @@ function useAutoFetchWhenNotScrollable({
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
const { filters } = useMemoFilterContext();
const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
@@ -136,8 +153,11 @@ const PagedMemoList = (props: Props) => {
{ enabled: props.enabled ?? true },
);
// Queries can start as soon as routing is unlocked, but memo content stays
// hidden until settings that control its presentation have settled.
const isDisplayPending = isLoading || !authInitialized || !instanceInitialized;
// Only show the spinner once loading exceeds the delay, so fast loads don't flash it.
const showLoader = useDelayedFlag(isLoading, LOADING_INDICATOR_DELAY_MS);
const showLoader = useDelayedFlag(isDisplayPending, LOADING_INDICATOR_DELAY_MS);
// Flatten pages into a single array of memos
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
@@ -152,6 +172,7 @@ const PagedMemoList = (props: Props) => {
// Auto-fetch hook: fetches more content when page isn't scrollable
useAutoFetchWhenNotScrollable({
enabled: !isDisplayPending,
hasNextPage,
isFetchingNextPage,
memoCount: sortedMemoList.length,
@@ -160,7 +181,7 @@ const PagedMemoList = (props: Props) => {
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => {
if (!hasNextPage) return;
if (isDisplayPending || !hasNextPage) return;
const handleScroll = () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
@@ -171,7 +192,7 @@ const PagedMemoList = (props: Props) => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [isDisplayPending, hasNextPage, isFetchingNextPage, fetchNextPage]);
const leadingContent = props.renderLeading?.({ useGrid });
@@ -182,6 +203,18 @@ const PagedMemoList = (props: Props) => {
// Stable reference so MentionResolutionProvider's memo (keyed on the array) actually holds.
const contents = useMemo(() => sortedMemoList.map((memo) => memo.content), [sortedMemoList]);
const userNames = useMemo(
() =>
Array.from(
new Set(
sortedMemoList.flatMap((memo) => [
...(props.showCreator ? [memo.creator] : []),
...(memo.reactions ?? []).map((reaction) => reaction.creator),
]),
),
),
[props.showCreator, sortedMemoList],
);
const emptyPlaceholder =
!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 ? (
@@ -215,11 +248,11 @@ const PagedMemoList = (props: Props) => {
);
const children = (
<MentionResolutionProvider contents={contents}>
<MentionResolutionProvider contents={contents} userNames={userNames}>
<div ref={layoutMeasureRef} className="w-full">
<div className={cn("flex flex-col justify-start w-full mx-auto", useGrid ? "max-w-none" : "max-w-2xl")}>
{/* During initial load, show the spinner only after the delay; render nothing before then to avoid a flash. */}
{isLoading ? (
{isDisplayPending ? (
showLoader ? (
<Loader />
) : null
+21 -5
View File
@@ -17,6 +17,8 @@ interface AuthState {
userWebhooksSetting: UserSetting_WebhooksSetting | undefined;
userTagsSetting: UserSetting_TagsSetting | undefined;
shortcuts: Shortcut[];
/** Authentication identity has settled, while user settings may still be loading. */
isIdentityInitialized: boolean;
isInitialized: boolean;
isLoading: boolean;
}
@@ -37,6 +39,7 @@ const UNAUTHENTICATED_STATE: AuthState = {
userWebhooksSetting: undefined,
userTagsSetting: undefined,
shortcuts: [],
isIdentityInitialized: true,
isInitialized: true,
isLoading: false,
};
@@ -49,6 +52,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userWebhooksSetting: undefined,
userTagsSetting: undefined,
shortcuts: [],
isIdentityInitialized: false,
isInitialized: false,
isLoading: true,
});
@@ -72,7 +76,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
// `initialize` also runs after sign-in, when the previous unauthenticated
// state is already marked initialized. Reset the full-readiness flag so
// consumers cannot render with the new identity and stale/default settings.
setState((prev) => ({ ...prev, isInitialized: false, isLoading: true }));
// Try to get or refresh the access token.
// This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage
@@ -102,18 +109,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}
// Publish the verified identity immediately so route modules and their
// data queries can start while display-sensitive settings are loading.
// Memo rendering remains gated on the full `isInitialized` state.
setState((prev) => ({
...prev,
currentUser,
isIdentityInitialized: true,
}));
queryClient.setQueryData(userKeys.currentUser(), currentUser);
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
const settings = await fetchUserSettings(currentUser.name);
setState({
currentUser,
...settings,
isIdentityInitialized: true,
isInitialized: true,
isLoading: false,
});
// Pre-populate React Query cache
queryClient.setQueryData(userKeys.currentUser(), currentUser);
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
} catch (error) {
console.error("Failed to initialize auth:", error);
clearAccessToken();
+37 -21
View File
@@ -28,6 +28,8 @@ const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
interface InstanceState {
profile: InstanceProfile;
settings: InstanceSetting[];
/** Instance profile has settled, while non-routing settings may still be loading. */
isProfileInitialized: boolean;
isInitialized: boolean;
isLoading: boolean;
// True only when the profile was successfully fetched from the server.
@@ -54,6 +56,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<InstanceState>({
profile: create(InstanceProfileSchema, {}),
settings: [],
isProfileInitialized: false,
isInitialized: false,
isLoading: true,
profileLoaded: false,
@@ -104,31 +107,44 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
const profile = await instanceServiceClient.getInstanceProfile({});
const settingsResponse = await instanceServiceClient.batchGetInstanceSettings({
const profileRequest = instanceServiceClient
.getInstanceProfile({})
.then((profile) => {
setState((prev) => ({
...prev,
profile,
isProfileInitialized: true,
profileLoaded: true,
}));
})
.catch((error) => {
console.error("Failed to initialize instance profile:", error);
setState((prev) => ({ ...prev, isProfileInitialized: true }));
});
const settingsRequest = instanceServiceClient
.batchGetInstanceSettings({
names: [buildInstanceSettingName(InstanceSetting_Key.GENERAL), buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED)],
})
.then((settingsResponse) => {
for (const setting of settingsResponse.settings) {
fetchedSettingsRef.current.add(setting.name);
}
setState((prev) => ({ ...prev, settings: settingsResponse.settings }));
})
.catch((error) => {
console.error("Failed to initialize instance settings:", error);
});
for (const setting of settingsResponse.settings) {
fetchedSettingsRef.current.add(setting.name);
}
setState({
profile,
settings: settingsResponse.settings,
isInitialized: true,
isLoading: false,
profileLoaded: true,
});
} catch (error) {
console.error("Failed to initialize instance:", error);
setState((prev) => ({
...prev,
isInitialized: true,
isLoading: false,
}));
}
// Profile and settings are independent. Starting both together removes one
// network round trip; the profile can unlock routing before settings settle.
await Promise.all([profileRequest, settingsRequest]);
setState((prev) => ({
...prev,
isInitialized: true,
isLoading: false,
}));
}, []);
const fetchSettings = useCallback(async (keys: InstanceSetting_Key[]) => {
+4 -3
View File
@@ -19,6 +19,7 @@ export interface FilteredMemoStats {
export interface UseFilteredMemoStatsOptions {
userName?: string;
context?: MemoExplorerContext;
enabled?: boolean;
}
const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD");
@@ -35,12 +36,12 @@ const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => {
};
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName, context } = options;
const { userName, context, enabled = true } = options;
const currentUser = useCurrentUser();
const { timeBasis } = useView();
// home/profile: use backend per-user stats (full tag set, not page-limited)
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName, { enabled });
// explore/archived: fetch backend grouped stats and aggregate them locally.
// ListAllUserStats AND's the request filter with the server's auth filter, so
// private memos are not included unless explicitly visible to the current user.
@@ -53,7 +54,7 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
: {};
const shouldFetchAllUserStats = context === "explore" || (context === "archived" && !!currentUser?.name);
const { data: allUserStats = [], isLoading: isLoadingAllUserStats } = useAllUserStats(allUserStatsRequest, {
enabled: shouldFetchAllUserStats,
enabled: enabled && shouldFetchAllUserStats,
});
const data = useMemo(() => {
+56
View File
@@ -0,0 +1,56 @@
import { type RefObject, useEffect, useRef, useState } from "react";
const DEFAULT_ROOT_MARGIN = "400px 0px";
interface UseNearViewportOptions {
rootMargin?: string;
}
interface UseNearViewportResult<T extends Element> {
ref: RefObject<T | null>;
isNearViewport: boolean;
}
/**
* Turns true once the observed element approaches the viewport. The flag stays
* true after the first intersection so callers can safely start one-way work
* such as data fetching without cancelling it when the element scrolls away.
*/
export function useNearViewport<T extends Element>(options: UseNearViewportOptions = {}): UseNearViewportResult<T> {
const { rootMargin = DEFAULT_ROOT_MARGIN } = options;
const ref = useRef<T>(null);
const [isNearViewport, setIsNearViewport] = useState(false);
useEffect(() => {
if (isNearViewport) {
return;
}
const element = ref.current;
if (!element) {
return;
}
if (typeof IntersectionObserver === "undefined") {
setIsNearViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
setIsNearViewport(true);
observer.disconnect();
},
{ rootMargin },
);
observer.observe(element);
return () => observer.disconnect();
}, [isNearViewport, rootMargin]);
return { ref, isNearViewport };
}
+23 -9
View File
@@ -3,7 +3,7 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { shortcutServiceClient, userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { buildUserSettingName } from "@/lib/resource-names";
import { buildUserSettingName, userNamePrefix } from "@/lib/resource-names";
import {
type ListAllUserStatsRequest,
ListAllUserStatsRequestSchema,
@@ -34,7 +34,7 @@ export const userKeys = {
byUsernames: (usernames: string[]) => [...userKeys.all, "byUsernames", ...[...usernames].sort()] as const,
};
const userDetailQueryOptions = (name: string) =>
export const userDetailQueryOptions = (name: string) =>
queryOptions({
queryKey: userKeys.detail(name),
queryFn: () => userServiceClient.getUser({ name }),
@@ -48,7 +48,7 @@ export function useUser(name: string, options?: { enabled?: boolean }) {
});
}
export function useUserStats(username?: string) {
export function useUserStats(username?: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: username ? userKeys.userStats(username) : userKeys.stats(),
queryFn: async () => {
@@ -58,7 +58,7 @@ export function useUserStats(username?: string) {
const stats = await userServiceClient.getUserStats({ name: username });
return stats;
},
enabled: !!username,
enabled: !!username && (options?.enabled ?? true),
});
}
@@ -257,9 +257,9 @@ export function useUpdateUserGeneralSetting(currentUserName?: string) {
}
// Hook to fetch multiple users by names (returns Map<name, User>)
export function useUsersByNames(names: string[]) {
export function useUsersByNames(names: string[], options?: { enabled?: boolean }) {
const queryClient = useQueryClient();
const enabled = names.length > 0;
const enabled = (options?.enabled ?? true) && names.length > 0;
const uniqueNames = Array.from(new Set(names));
return useQuery({
@@ -296,15 +296,29 @@ export function useUsersByUsernames(usernames: string[], options?: { enabled?: b
return useQuery({
queryKey: userKeys.byUsernames(uniqueUsernames),
queryFn: async () => {
const usersByUsername = new Map<string, User>();
const missingUsernames: string[] = [];
for (const username of uniqueUsernames) {
const detailKey = userKeys.detail(`${userNamePrefix}${username}`);
const cachedUser = queryClient.getQueryData<User>(detailKey);
const cachedState = queryClient.getQueryState(detailKey);
const cacheIsFresh = cachedState ? Date.now() - cachedState.dataUpdatedAt < USER_PROFILE_STALE_TIME : false;
if (cachedUser && cacheIsFresh) {
usersByUsername.set(username, cachedUser);
} else {
missingUsernames.push(username);
}
}
const batches = [];
for (let i = 0; i < uniqueUsernames.length; i += BATCH_GET_USERS_LIMIT) {
batches.push(uniqueUsernames.slice(i, i + BATCH_GET_USERS_LIMIT));
for (let i = 0; i < missingUsernames.length; i += BATCH_GET_USERS_LIMIT) {
batches.push(missingUsernames.slice(i, i + BATCH_GET_USERS_LIMIT));
}
const responses = await Promise.all(batches.map((batch) => userServiceClient.batchGetUsers({ usernames: batch })));
const users = responses.flatMap((response) => response.users);
const usersByUsername = new Map(users.map((user) => [user.username, user] as const));
for (const user of users) {
usersByUsername.set(user.username, user);
queryClient.setQueryData(userKeys.detail(user.name), user);
}
+15 -2
View File
@@ -4,6 +4,8 @@ import type { MemoExplorerContext } from "@/components/MemoExplorer";
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
import MobileHeader from "@/components/MobileHeader";
import { userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import useMediaQuery from "@/hooks/useMediaQuery";
@@ -18,9 +20,12 @@ const MAIN_CONTENT_CLASS_NAME = "w-full min-h-full min-w-0 flex-1";
const MainLayout = () => {
const md = useMediaQuery("md");
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
const location = useLocation();
const currentUser = useCurrentUser();
const [profileUserName, setProfileUserName] = useState<string | undefined>();
const [mobileExplorerOpen, setMobileExplorerOpen] = useState(false);
const showMemoExplorer = location.pathname !== Routes.ABOUT;
// Determine context based on current route
@@ -65,12 +70,20 @@ const MainLayout = () => {
return undefined;
}, [context, currentUser, profileUserName]);
const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context });
// The feed query starts as soon as identity/profile routing is ready. Keep
// auxiliary statistics behind full settings initialization, and do not fetch
// mobile drawer data until the user actually opens it.
const statsEnabled = showMemoExplorer && authInitialized && instanceInitialized && (md || mobileExplorerOpen);
const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context, enabled: statsEnabled });
const memoExplorerProps = { context, statisticsData: statistics, tagCount: tags };
return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center md:flex-row md:items-start">
{!md && <MobileHeader>{showMemoExplorer && <MemoExplorerDrawer {...memoExplorerProps} />}</MobileHeader>}
{!md && (
<MobileHeader>
{showMemoExplorer && <MemoExplorerDrawer {...memoExplorerProps} onOpenChange={setMobileExplorerOpen} />}
</MobileHeader>
)}
{md && showMemoExplorer && (
<div className={DESKTOP_EXPLORER_CLASS_NAME}>
<MemoExplorer className="px-3 py-6" {...memoExplorerProps} />
+4
View File
@@ -19,6 +19,10 @@ export const extractMemoIdFromName = (name: string) => {
return name.split(memoNamePrefix).pop() || "";
};
export const extractUsernameFromName = (name: string) => {
return name.startsWith(userNamePrefix) ? name.slice(userNamePrefix.length) : name;
};
export const extractIdentityProviderUidFromName = (name: string) => {
return name.split(identityProviderNamePrefix).pop() || "";
};
+6 -3
View File
@@ -25,8 +25,8 @@ applyLocaleEarly();
// Inner component that initializes contexts
function AppInitializer({ children }: { children: React.ReactNode }) {
const { isInitialized: authInitialized, initialize: initAuth, currentUser } = useAuth();
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
const { isIdentityInitialized, initialize: initAuth, currentUser } = useAuth();
const { isProfileInitialized, initialize: initInstance } = useInstance();
const initStartedRef = useRef(false);
// Initialize on mount - run in parallel for better performance
@@ -48,7 +48,10 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
// Live refresh: listen for memo changes via SSE and invalidate caches.
useLiveMemoRefresh();
if (!authInitialized || !instanceInitialized) {
// Route loading and feed requests only need the verified identity and the
// instance profile. Display-sensitive settings continue in the background;
// PagedMemoList keeps memo content hidden until they have settled.
if (!isIdentityInitialized || !isProfileInitialized) {
return null;
}
-3
View File
@@ -3,7 +3,6 @@ import MemoEditor from "@/components/MemoEditor";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
@@ -15,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
const Home = () => {
const user = useCurrentUser();
const t = useTranslate();
const { isInitialized } = useInstance();
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
@@ -40,7 +38,6 @@ const Home = () => {
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
renderLeading={({ useGrid }) => (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
+11 -2
View File
@@ -7,6 +7,8 @@ import { MentionResolutionProvider } from "@/components/MemoContent/MentionResol
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useMediaQuery from "@/hooks/useMediaQuery";
import useMemoDetailError from "@/hooks/useMemoDetailError";
import { useInfiniteMemoComments, useMemo } from "@/hooks/useMemoQueries";
@@ -17,6 +19,8 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
const MemoDetail = () => {
const md = useMediaQuery("md");
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false);
const params = useParams();
const location = useLocation();
@@ -76,7 +80,9 @@ const MemoDetail = () => {
}
}
if (isLoading || !memo) {
// Start the memo and comment requests as soon as routing is unlocked, but do
// not expose content before tag-blur and instance display settings settle.
if (isLoading || !memo || !authInitialized || !instanceInitialized) {
return null;
}
@@ -85,6 +91,9 @@ const MemoDetail = () => {
? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) }
: memo;
const mentionResolutionContents = [displayMemo.content, ...comments.map((comment) => comment.content)];
const userResolutionNames = Array.from(
new Set([displayMemo, ...comments].flatMap((item) => [item.creator, ...(item.reactions ?? []).map((reaction) => reaction.creator)])),
);
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
@@ -93,7 +102,7 @@ const MemoDetail = () => {
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</MobileHeader>
)}
<MentionResolutionProvider contents={mentionResolutionContents}>
<MentionResolutionProvider contents={mentionResolutionContents} userNames={userResolutionNames}>
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-6")}>
<div className={cn("w-full md:w-[calc(100%-16.5rem)]")}>
{parentMemo && (
+15
View File
@@ -1,8 +1,23 @@
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath } from "@/utils/auth-redirect";
import { ROUTES } from "./routes";
/** Waits for instance settings used by public/auth pages to settle. */
export const RequireInstanceInitializationRoute = () => {
const { isInitialized } = useInstance();
return isInitialized ? <Outlet /> : null;
};
/** Keeps non-feed authenticated pages behind all display-sensitive settings. */
export const RequireFullInitializationRoute = () => {
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
return authInitialized && instanceInitialized ? <Outlet /> : null;
};
/**
* Index-route gate mounted at `/`. Authenticated visitors fall through to the
* nested Home page; unauthenticated visitors are redirected to `/explore`,
+32 -10
View File
@@ -4,7 +4,13 @@ import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout";
import { lazyWithReload } from "@/utils/lazy";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
import {
LandingRoute,
RequireAuthRoute,
RequireFullInitializationRoute,
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "./guards";
import { ROUTES } from "./routes";
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
@@ -47,11 +53,16 @@ export const routeConfig: RouteObject[] = [
// one-time OAuth state. Keep it outside the guest-only subtree.
{ path: "callback", element: <AuthCallback /> },
{
element: <RequireGuestRoute />,
element: <RequireInstanceInitializationRoute />,
children: [
{ path: "", element: <SignIn /> },
{ path: "admin", element: <AdminSignIn /> },
{ path: "signup", element: <SignUp /> },
{
element: <RequireGuestRoute />,
children: [
{ path: "", element: <SignIn /> },
{ path: "admin", element: <AdminSignIn /> },
{ path: "signup", element: <SignUp /> },
],
},
],
},
],
@@ -68,14 +79,20 @@ export const routeConfig: RouteObject[] = [
element: <LandingRoute />,
children: [{ index: true, element: <Home /> }],
},
{ path: Routes.ABOUT, element: <About /> },
{
element: <RequireInstanceInitializationRoute />,
children: [{ path: Routes.ABOUT, element: <About /> }],
},
{ path: Routes.EXPLORE, element: <Explore /> },
{ path: "u/:username", element: <UserProfile /> },
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.ARCHIVED, element: <Archived /> },
{ path: Routes.SHORTCUTS, element: <Shortcuts /> },
{
element: <RequireFullInitializationRoute />,
children: [{ path: Routes.SHORTCUTS, element: <Shortcuts /> }],
},
],
},
],
@@ -85,9 +102,14 @@ export const routeConfig: RouteObject[] = [
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },
{
element: <RequireFullInitializationRoute />,
children: [
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },
],
},
],
},
{ path: "403", element: <PermissionDenied /> },
-17
View File
@@ -70,23 +70,6 @@ export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked:
return toggleTaskAtLine(markdown, task.lineNumber, checked);
}
export function countTasks(markdown: string): {
total: number;
completed: number;
incomplete: number;
} {
const tasks = extractTasksFromAst(markdown);
const total = tasks.length;
const completed = tasks.filter((t) => t.checked).length;
return {
total,
completed,
incomplete: total - completed,
};
}
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
const tasks = extractTasksFromAst(markdown);
+7 -1
View File
@@ -18,7 +18,13 @@ vi.mock("@/contexts/InstanceContext", () => ({
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => (key === "common.version" ? "Version" : key),
useTranslate: () => (key: string) =>
(
{
"common.version": "Version",
"about.powered-by": "Powered by Memos",
} as Record<string, string>
)[key] ?? key,
}));
describe("<About>", () => {
@@ -0,0 +1,88 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const authState = vi.hoisted(() => ({ hasToken: false }));
const clients = vi.hoisted(() => ({
getCurrentUser: vi.fn(),
listShortcuts: vi.fn(),
listUserSettings: vi.fn(),
}));
vi.mock("@/auth-state", () => ({
clearAccessToken: vi.fn(),
getAccessToken: () => (authState.hasToken ? "token" : undefined),
}));
vi.mock("@/connect", () => ({
authServiceClient: {
getCurrentUser: clients.getCurrentUser,
signOut: vi.fn(),
},
refreshAccessToken: vi.fn(async () => undefined),
shortcutServiceClient: {
listShortcuts: clients.listShortcuts,
},
userServiceClient: {
listUserSettings: clients.listUserSettings,
},
}));
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
const Probe = () => {
const { currentUser, initialize, isInitialized } = useAuth();
return (
<div>
<span data-testid="initialized">{isInitialized ? "yes" : "no"}</span>
<span data-testid="user">{currentUser?.name ?? "none"}</span>
<button type="button" onClick={() => void initialize()}>
initialize
</button>
</div>
);
};
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
describe("AuthProvider initialization", () => {
beforeEach(() => {
authState.hasToken = false;
clients.getCurrentUser.mockReset();
clients.listShortcuts.mockReset();
clients.listUserSettings.mockReset();
});
it("resets full readiness while post-sign-in settings are pending", async () => {
let resolveSettings!: (value: { settings: [] }) => void;
let resolveShortcuts!: (value: { shortcuts: [] }) => void;
clients.getCurrentUser.mockResolvedValue({ user: { name: "users/alice", username: "alice" } });
clients.listUserSettings.mockImplementation(
() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)),
);
clients.listShortcuts.mockImplementation(
() => new Promise<{ shortcuts: [] }>((resolve) => (resolveShortcuts = resolve)),
);
render(<Probe />, { wrapper });
// Settle the initial unauthenticated pass; this reproduces the state from
// which PasswordSignInForm and AuthCallback invoke initialize again.
fireEvent.click(screen.getByRole("button", { name: "initialize" }));
await waitFor(() => expect(screen.getByTestId("initialized")).toHaveTextContent("yes"));
authState.hasToken = true;
fireEvent.click(screen.getByRole("button", { name: "initialize" }));
await waitFor(() => expect(screen.getByTestId("user")).toHaveTextContent("users/alice"));
expect(screen.getByTestId("initialized")).toHaveTextContent("no");
resolveSettings({ settings: [] });
resolveShortcuts({ shortcuts: [] });
await waitFor(() => expect(screen.getByTestId("initialized")).toHaveTextContent("yes"));
});
});
+14
View File
@@ -106,4 +106,18 @@ describe("useFilteredMemoStats", () => {
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("disables both statistics queries when deferred", () => {
mockUseView.mockReturnValue({
timeBasis: "create_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setTimeBasis: vi.fn(),
});
renderHook(() => useFilteredMemoStats({ userName: "users/test", context: "explore", enabled: false }), { wrapper });
expect(useUserStats).toHaveBeenCalledWith("users/test", { enabled: false });
expect(useAllUserStats).toHaveBeenCalledWith(expect.anything(), { enabled: false });
});
});
+55 -2
View File
@@ -1,15 +1,31 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/hooks/useCurrentUser", () => ({
__esModule: true,
default: vi.fn(),
}));
const initialization = vi.hoisted(() => ({ auth: true, instance: true }));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: initialization.auth }),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: initialization.instance }),
}));
import useCurrentUser from "@/hooks/useCurrentUser";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
import {
LandingRoute,
RequireAuthRoute,
RequireFullInitializationRoute,
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "@/router/guards";
const mockedUseCurrentUser = vi.mocked(useCurrentUser);
@@ -24,6 +40,43 @@ const LocationProbe = () => {
const renderAt = (initialEntry: string, children: ReactNode) =>
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
beforeEach(() => {
initialization.auth = true;
initialization.instance = true;
});
describe("initialization guards", () => {
it("keeps instance-dependent pages hidden until instance settings settle", () => {
initialization.instance = false;
renderAt(
"/auth",
<Routes>
<Route element={<RequireInstanceInitializationRoute />}>
<Route path="/auth" element={<div data-testid="instance-ready">ready</div>} />
</Route>
</Routes>,
);
expect(screen.queryByTestId("instance-ready")).not.toBeInTheDocument();
});
it("keeps non-feed pages hidden until both contexts fully initialize", () => {
initialization.auth = false;
renderAt(
"/setting",
<Routes>
<Route element={<RequireFullInitializationRoute />}>
<Route path="/setting" element={<div data-testid="fully-ready">ready</div>} />
</Route>
</Routes>,
);
expect(screen.queryByTestId("fully-ready")).not.toBeInTheDocument();
});
});
describe("LandingRoute", () => {
it("renders the nested home page for an authenticated visitor at /", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
@@ -0,0 +1,34 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
import { hasMathSyntax } from "@/components/MemoContent/math";
vi.mock("@/components/MemoContent/math", () => ({
hasMathSyntax: vi.fn(() => false),
}));
describe("<MemoMarkdownRenderer /> memoization", () => {
beforeEach(() => {
vi.mocked(hasMathSyntax).mockClear();
});
it("does not parse again when only the resolved mention Set identity changes", () => {
const { rerender } = render(
<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />,
);
rerender(<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />);
expect(hasMathSyntax).toHaveBeenCalledTimes(1);
});
it("renders again when the relevant resolved mentions change", () => {
const { rerender } = render(
<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set<string>()} />,
);
rerender(<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />);
expect(hasMathSyntax).toHaveBeenCalledTimes(2);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useNearViewport } from "@/hooks/useNearViewport";
let intersectionCallback: IntersectionObserverCallback;
let observerOptions: IntersectionObserverInit | undefined;
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
intersectionCallback = callback;
observerOptions = options;
}
disconnect = vi.fn();
observe = vi.fn();
takeRecords = vi.fn(() => []);
unobserve = vi.fn();
}
const Probe = () => {
const { ref, isNearViewport } = useNearViewport<HTMLDivElement>();
return <div ref={ref}>{isNearViewport ? "near" : "waiting"}</div>;
};
describe("useNearViewport", () => {
beforeEach(() => {
observerOptions = undefined;
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("stays dormant until the target approaches the viewport", () => {
render(<Probe />);
expect(screen.getByText("waiting")).toBeInTheDocument();
expect(observerOptions).toEqual({ rootMargin: "400px 0px" });
act(() => {
intersectionCallback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
expect(screen.getByText("near")).toBeInTheDocument();
});
});
+48 -4
View File
@@ -1,17 +1,22 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false }));
const feed = vi.hoisted(() => ({ memos: [] as unknown[] }));
const feed = vi.hoisted(() => ({
memos: [] as unknown[],
hasNextPage: false,
fetchNextPage: vi.fn(async () => undefined),
}));
const readiness = vi.hoisted(() => ({ auth: true, instance: true }));
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
data: { pages: [{ memos: feed.memos, nextPageToken: "" }] },
fetchNextPage: vi.fn(async () => undefined),
hasNextPage: false,
fetchNextPage: feed.fetchNextPage,
hasNextPage: feed.hasNextPage,
isFetchingNextPage: false,
isLoading: false,
}),
@@ -21,6 +26,14 @@ vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: readiness.auth }),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: readiness.instance }),
}));
vi.mock("@/contexts/ViewContext", () => ({
useView: () => view,
}));
@@ -54,6 +67,37 @@ describe("<PagedMemoList>", () => {
view.maxColumns = 1;
view.compactMode = false;
feed.memos = [];
feed.hasNextPage = false;
feed.fetchNextPage.mockClear();
readiness.auth = true;
readiness.instance = true;
});
it("does not render fetched memo content before display settings settle", () => {
feed.memos = [memo];
readiness.auth = false;
const renderer = vi.fn((m: Memo) => <div key={m.name}>{m.content}</div>);
renderList(renderer);
expect(renderer).not.toHaveBeenCalled();
expect(screen.queryByText("hello")).not.toBeInTheDocument();
});
it("does not auto-fetch more pages while display settings are pending", async () => {
vi.useFakeTimers();
try {
feed.memos = [memo];
feed.hasNextPage = true;
readiness.auth = false;
renderList();
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(feed.fetchNextPage).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("uses the tile sprite Placeholder for the empty state", () => {
+49 -1
View File
@@ -1,7 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MentionResolutionProvider,
useResolvedMentionUsernames,
useResolvedUser,
} from "@/components/MemoContent/MentionResolutionContext";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import { memoKeys } from "@/hooks/useMemoQueries";
import { useUser, userKeys, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
@@ -84,6 +89,49 @@ describe("query deduplication", () => {
expect(clients.getUser).not.toHaveBeenCalled();
});
it("reuses cached user details instead of issuing another username batch", async () => {
const alice = { name: "users/alice", username: "alice" } as User;
const queryClient = createQueryClient();
queryClient.setQueryData(userKeys.detail(alice.name), alice);
const batch = renderHook(() => useUsersByUsernames(["alice"]), { wrapper: createWrapper(queryClient) });
await waitFor(() => expect(batch.result.current.isSuccess).toBe(true));
expect(batch.result.current.data?.get("alice")).toBe(alice);
expect(clients.batchGetUsers).not.toHaveBeenCalled();
});
it("resolves feed creators and mentions through one shared username batch", async () => {
const alice = { name: "users/alice", username: "alice" } as User;
const bob = { name: "users/bob", username: "bob" } as User;
clients.batchGetUsers.mockResolvedValue({ users: [alice, bob] });
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MentionResolutionProvider contents={["Hello @bob"]} userNames={[alice.name]}>
{children}
</MentionResolutionProvider>
</QueryClientProvider>
);
const { result } = renderHook(
() => ({
creator: useResolvedUser(alice.name),
mentions: useResolvedMentionUsernames("Hello @bob"),
}),
{ wrapper },
);
await waitFor(() => {
expect(result.current.creator).toBe(alice);
expect(result.current.mentions.has("bob")).toBe(true);
});
expect(clients.batchGetUsers).toHaveBeenCalledTimes(1);
expect(clients.batchGetUsers).toHaveBeenCalledWith({ usernames: ["bob", "alice"] });
expect(clients.getUser).not.toHaveBeenCalled();
});
it("reuses memo list data and canonical detail queries when resolving relations", async () => {
const cachedMemo = { name: "memos/cached", snippet: "Already in the list" } as Memo;
const missingMemo = { name: "memos/missing", snippet: "Fetched once" } as Memo;
+20 -1
View File
@@ -2,7 +2,12 @@ import { isValidElement } from "react";
import type { RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { routeConfig, ROUTES } from "@/router";
import { RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
import {
RequireAuthRoute,
RequireFullInitializationRoute,
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "@/router/guards";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
@@ -48,6 +53,7 @@ describe("router configuration", () => {
it("wraps the remaining /auth children in RequireGuestRoute", () => {
for (const path of ["", "admin", "signup"]) {
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
expect(hasAncestorOfType(routeConfig, path, RequireInstanceInitializationRoute)).toBe(true);
}
});
@@ -57,6 +63,19 @@ describe("router configuration", () => {
}
});
it("keeps non-feed pages behind their full initialization requirements", () => {
for (const path of [ROUTES.SHORTCUTS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(true);
}
expect(hasAncestorOfType(routeConfig, ROUTES.ABOUT, RequireInstanceInitializationRoute)).toBe(true);
});
it("leaves memo feeds available for early queries", () => {
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);
}
});
it("leaves public pages outside RequireAuthRoute", () => {
for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);