diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index cb29cf7e..ee92fe02 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -9,7 +9,7 @@ interface UseMemoInitOptions { memo?: Memo; cacheKey?: string; username: string; - autoFocus?: boolean; + autoFocus?: boolean | (() => boolean); defaultVisibility?: Visibility; defaultCreateTime?: Date; } @@ -59,7 +59,7 @@ export const useMemoInit = ({ if (cachedCursor !== undefined) { editorRef.current?.setCursor(cachedCursor); } - if (autoFocus) { + if (typeof autoFocus === "function" ? autoFocus() : autoFocus) { editorRef.current?.focus(); } }, 100); diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 911f4af0..48d56b2b 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -12,7 +12,8 @@ export interface MemoEditorProps { parentMemoName?: string; /** Assigns a newly created top-level memo to this Space. Ignored for edits and comments. */ defaultSpace?: string; - autoFocus?: boolean; + /** A callback can decide whether focus is still appropriate after draft restoration. */ + autoFocus?: boolean | (() => boolean); /** * Marks the instance as *hosted*: a host (the global composer dialog) presents * the editor in the focus-mode layout and owns that frame. The editor mounts diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx index 74757fa9..e1395a20 100644 --- a/web/src/contexts/GlobalMemoEditorContext.tsx +++ b/web/src/contexts/GlobalMemoEditorContext.tsx @@ -9,13 +9,17 @@ import { useAppSidebar } from "@/contexts/AppSidebarContext"; import { useAuth } from "@/contexts/AuthContext"; 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 { /** Whether the signed-in user is ready to compose; gates every visible entry point. */ canOpen: boolean; openEditor: () => void; + /** Claims the initial desktop Home focus once, after the editor restores its draft. */ + claimHomeAutoFocus: () => boolean; } const GlobalMemoEditorContext = createContext(null); @@ -42,6 +46,9 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) const currentUserName = useCurrentUser()?.name; const { selectedSpaceName } = 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 { setMobileOpen, setQuickFindOpen } = useAppSidebar(); const routePolicy = getRouteActionPolicy(location.pathname); const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined; @@ -56,6 +63,26 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) const returnFocusRef = useRef(null); const openRequestVersionRef = useRef(0); + useEffect(() => { + if ( + location.key !== initialHome.location.key || + location.pathname !== initialHome.location.pathname || + location.search !== initialHome.location.search || + currentUserName !== initialHome.user || + selectedSpaceName !== initialHome.space || + !desktop + ) { + homeAutoFocusPending.current = false; + } + }, [location, currentUserName, selectedSpaceName, desktop, initialHome]); + + const claimHomeAutoFocus = useCallback(() => { + const pending = homeAutoFocusPending.current; + homeAutoFocusPending.current = false; + // A user who already focused another control takes precedence over startup focus. + return pending && (document.activeElement === document.body || document.activeElement === null); + }, []); + const closeEditor = useCallback(() => { openRequestVersionRef.current += 1; isSavingRef.current = false; @@ -74,6 +101,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) const openEditor = useCallback(() => { if (!canOpen || !currentUserName) return; + homeAutoFocusPending.current = false; // Owned here so no caller can leave a sidebar surface open underneath. setMobileOpen(false); @@ -112,7 +140,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) }, []); const editorIsOpen = opened !== undefined && opened.user === currentUserName; - const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]); + const value = useMemo(() => ({ canOpen, openEditor, claimHomeAutoFocus }), [canOpen, openEditor, claimHomeAutoFocus]); return ( diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 5e236829..4a0d4b7e 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -4,6 +4,7 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util import MemoView from "@/components/MemoView"; import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList"; import { useAuth } from "@/contexts/AuthContext"; +import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { NewMemoProvider } from "@/contexts/NewMemoContext"; import { useSpaceContext } from "@/contexts/SpaceContext"; @@ -18,6 +19,7 @@ const Home = () => { const user = useCurrentUser(); const t = useTranslate(); const { isUserSettingsInitialized } = useAuth(); + const { claimHomeAutoFocus } = useGlobalMemoEditor(); const { filters } = useMemoFilterContext(); const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext(); const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]); @@ -53,6 +55,7 @@ const Home = () => { return ( ({ setQuickFindOpen: vi.fn(), selectedSpaceName: undefined as string | undefined, pathname: "/", + desktop: true, })); vi.mock("react-router-dom", async (importOriginal) => ({ @@ -42,6 +43,10 @@ vi.mock("@/hooks/useCurrentUser", () => ({ default: () => mocks.currentUser, })); +vi.mock("@/hooks/useMediaQuery", () => ({ + default: () => mocks.desktop, +})); + vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key, })); @@ -124,6 +129,57 @@ describe("GlobalMemoEditorProvider", () => { mocks.setQuickFindOpen.mockClear(); mocks.selectedSpaceName = undefined; mocks.pathname = "/"; + mocks.desktop = true; + }); + + it("allows Home autofocus once after settings finish loading", () => { + let claimFocus!: () => boolean; + const Probe = () => { + claimFocus = useGlobalMemoEditor().claimHomeAutoFocus; + return null; + }; + mocks.isUserSettingsInitialized = false; + const { rerender } = renderProvider(); + mocks.isUserSettingsInitialized = true; + rerender( + + + , + ); + + expect(claimFocus()).toBe(true); + expect(claimFocus()).toBe(false); + }); + + it.each(["mobile", "other route", "navigation", "Space change", "existing focus"])("does not autofocus Home after %s", (scenario) => { + let claimFocus!: () => boolean; + const Probe = () => { + claimFocus = useGlobalMemoEditor().claimHomeAutoFocus; + return ; + }; + if (scenario === "mobile") mocks.desktop = false; + if (scenario === "other route") mocks.pathname = "/explore"; + const { rerender } = renderProvider(); + + if (scenario === "navigation") mocks.pathname = "/inbox"; + if (scenario === "Space change") mocks.selectedSpaceName = "spaces/product"; + if (scenario === "existing focus") screen.getByRole("button", { name: "Another control" }).focus(); + rerender( + + + , + ); + + // Returning to the original route, Space, or viewport must not rearm focus. + mocks.pathname = "/"; + mocks.selectedSpaceName = undefined; + mocks.desktop = true; + rerender( + + + , + ); + expect(claimFocus()).toBe(false); }); it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => { diff --git a/web/tests/home-loading-boundary.test.tsx b/web/tests/home-loading-boundary.test.tsx index 38f6db99..19d5c368 100644 --- a/web/tests/home-loading-boundary.test.tsx +++ b/web/tests/home-loading-boundary.test.tsx @@ -43,6 +43,10 @@ vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ isUserSettingsInitialized: true }), })); +vi.mock("@/contexts/GlobalMemoEditorContext", () => ({ + useGlobalMemoEditor: () => ({ claimHomeAutoFocus: () => true }), +})); + vi.mock("@/contexts/MemoFilterContext", () => ({ useMemoFilterContext: () => ({ filters: [] }), })); @@ -80,6 +84,7 @@ describe("", () => { expect(screen.getByTestId("memo-view")).toBeInTheDocument(); expect(state.listProps).toMatchObject({ contextFilter: undefined }); expect(state.editorProps).toMatchObject({ cacheKey: "home-memo-editor", defaultSpace: undefined }); + expect(state.editorProps?.autoFocus).toEqual(expect.any(Function)); }); it("filters the feed and sets new memo placement to the selected Space", () => { diff --git a/web/tests/use-memo-init.test.tsx b/web/tests/use-memo-init.test.tsx index da71fc4c..2ca675c9 100644 --- a/web/tests/use-memo-init.test.tsx +++ b/web/tests/use-memo-init.test.tsx @@ -1,5 +1,5 @@ import { create } from "@bufbuild/protobuf"; -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import type { RefObject } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useMemoInit } from "@/components/MemoEditor/hooks/useMemoInit"; @@ -11,12 +11,13 @@ import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; const editorRef = { current: null } as RefObject; let getEditorState: ReturnType["getState"]; -function Probe() { +function Probe({ autoFocus }: { autoFocus?: boolean | (() => boolean) }) { getEditorState = useEditorContext().getState; useMemoInit({ editorRef, username: "users/steven", cacheKey: "restored-draft", + autoFocus, }); return null; } @@ -33,9 +34,36 @@ describe("useMemoInit", () => { }); afterEach(() => { + editorRef.current = null; + vi.useRealTimers(); vi.unstubAllGlobals(); }); + it.each([true, false])("restores the draft and cursor before evaluating autofocus (%s)", (allowed) => { + vi.useFakeTimers(); + const key = cacheService.key("users/steven", "restored-draft"); + cacheService.saveNow(key, "An unfinished memo"); + cacheService.saveCursor(key, 5); + const focus = vi.fn(); + const setCursor = vi.fn(); + editorRef.current = { focus, setCursor } as unknown as EditorController; + const autoFocus = vi.fn(() => { + expect(getEditorState().content).toBe("An unfinished memo"); + expect(setCursor).toHaveBeenCalledWith(5); + return allowed; + }); + + render( + + + , + ); + expect(autoFocus).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(100)); + expect(autoFocus).toHaveBeenCalledOnce(); + expect(focus).toHaveBeenCalledTimes(allowed ? 1 : 0); + }); + it("restores uploaded attachment bindings with a new memo draft", async () => { const attachment = create(AttachmentSchema, { name: "attachments/image-one",