diff --git a/apps/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 46a242072868..9559546b0894 100644 --- a/apps/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/apps/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -92,7 +92,9 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa const orderBy = displayFilters?.order_by; useEffect(() => { - fetchIssues("init-loader", { canGroup: true, perPageCount: sub_group_by ? 10 : 30 }, viewId); + // 100 per group (was 30), as the list layout loads ungrouped: a board with up to 100 cards in a column + // loads in one request instead of one more request per long column after the board is shown. + fetchIssues("init-loader", { canGroup: true, perPageCount: sub_group_by ? 10 : 100 }, viewId); }, [fetchIssues, storeType, group_by, sub_group_by, viewId]); const fetchMoreIssues = useCallback( diff --git a/apps/web/components/issues/issue-layouts/kanban/block.tsx b/apps/web/components/issues/issue-layouts/kanban/block.tsx index 4e5587f77d69..9a1f090a4ade 100644 --- a/apps/web/components/issues/issue-layouts/kanban/block.tsx +++ b/apps/web/components/issues/issue-layouts/kanban/block.tsx @@ -10,6 +10,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { preload } from "swr"; // plane helpers import { MoreHorizontalOutline } from "@makeplane/propel/icons"; import { useOutsideClickDetector } from "@plane/hooks"; @@ -165,15 +166,34 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB const workspaceSlug = routerWorkspaceSlug?.toString(); // hooks const { getProjectIdentifierById } = useProject(); - const { getIsIssuePeeked } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); + const { + getIsIssuePeeked, + issue: { fetchIssue }, + } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic); const { isMobile } = usePlatformOS(); + const preloadTimerRef = useRef | undefined>(undefined); // handlers const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile); const issue = issuesMap[issueId]; + // Preload the peek overview while the pointer rests on the card: the click comes 200-600 ms later and + // the peek overview (same SWR key as in peek-overview/root.tsx) then opens without waiting for the + // request. The delay keeps a pointer that only crosses the board from firing requests. + const handlePreloadPeek = () => { + const projectId = issue?.project_id; + if (isMobile || isEpic || !workspaceSlug || !projectId || issue?.tempId) return; + preloadTimerRef.current = setTimeout(() => { + void preload(["peek-issue", workspaceSlug, projectId, issueId], () => + fetchIssue(workspaceSlug, projectId, issueId).catch(() => undefined) + ); + }, 120); + }; + const cancelPreloadPeek = () => clearTimeout(preloadTimerRef.current); + useEffect(() => cancelPreloadPeek, []); + const { setIsDragging: setIsKanbanDragging } = useKanbanView(); const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false); @@ -247,6 +267,8 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB id={`issue-${issueId}`} // make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps className={cn("group/kanban-block relative mb-2", { "z-[1]": isCurrentBlockDragging })} + onMouseEnter={handlePreloadPeek} + onMouseLeave={cancelPreloadPeek} onDragStart={() => { if (isDragAllowed) setIsCurrentBlockDragging(true); else { diff --git a/apps/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/apps/web/components/issues/issue-layouts/roots/project-layout-root.tsx index 8e56cfdc001f..4b2e24614f2d 100644 --- a/apps/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/apps/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -53,6 +53,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() { const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined; const activeLayout = workItemFilters?.displayFilters?.layout; + // keep this key equal to the preload in layouts/auth-layout/project-wrapper.tsx useSWR( workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { diff --git a/apps/web/layouts/auth-layout/project-wrapper.tsx b/apps/web/layouts/auth-layout/project-wrapper.tsx index 1a0a441474c3..0f79397b4a88 100644 --- a/apps/web/layouts/auth-layout/project-wrapper.tsx +++ b/apps/web/layouts/auth-layout/project-wrapper.tsx @@ -7,10 +7,11 @@ import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import useSWR from "swr"; +import { usePathname } from "next/navigation"; +import useSWR, { preload } from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { GANTT_TIMELINE_TYPE } from "@plane/types"; +import { EIssuesStoreType, GANTT_TIMELINE_TYPE } from "@plane/types"; // components import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction"; import { @@ -29,6 +30,7 @@ import { // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssues } from "@/hooks/store/use-issues"; import { useLabel } from "@/hooks/store/use-label"; import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; @@ -51,7 +53,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP const [isJoiningProject, setIsJoiningProject] = useState(false); // store hooks const { fetchUserProjectInfo, allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); - const { fetchProjectDetails } = useProject(); + const { fetchProjectDetails, getProjectById } = useProject(); const { joinProject } = useUserPermissions(); const { fetchAllCycles } = useCycle(); const { fetchModulesSlim, fetchModules } = useModule(); @@ -64,6 +66,8 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP const { data: currentUserData } = useUser(); const { fetchProjectLabels } = useLabel(); const { getProjectEstimates } = useProjectEstimates(); + const { issuesFilter: projectIssuesFilter } = useIssues(EIssuesStoreType.PROJECT); + const pathname = usePathname(); // derived values const hasPermissionToCurrentProject = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], @@ -72,6 +76,14 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP projectId ); const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId); + // The keys below contain the role, which is undefined for a moment after mount (the workspace's project + // roles arrive ~40 ms later): every key changed and all project data was fetched twice. Without a role + // the user sees the access restriction anyway, so fetch only once the role is known. + const roleKey = (key: string) => (currentProjectRole ? key : null); + // Data of a project feature that is turned off is not needed: skip it. The flags come with the + // workspace's project list, so wait for the project to be known before deciding. + const project = getProjectById(projectId); + const featureKey = (key: string, enabled: boolean | undefined) => (project && enabled ? roleKey(key) : null); const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug); // Initialize module timeline chart useEffect(() => { @@ -88,53 +100,57 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP useSWR(PROJECT_ME_INFORMATION(workspaceSlug, projectId), () => fetchUserProjectInfo(workspaceSlug, projectId)); // fetching project member preferences useSWR( - currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(projectId, currentProjectRole) : null, + currentUserData?.id ? roleKey(PROJECT_MEMBER_PREFERENCES(projectId, currentProjectRole)) : null, currentUserData?.id ? () => fetchProjectUserProperties(workspaceSlug, projectId) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); // fetching project labels - useSWR(PROJECT_LABELS(projectId, currentProjectRole), () => fetchProjectLabels(workspaceSlug, projectId), { + useSWR(roleKey(PROJECT_LABELS(projectId, currentProjectRole)), () => fetchProjectLabels(workspaceSlug, projectId), { revalidateIfStale: false, revalidateOnFocus: false, }); // fetching project members - useSWR(PROJECT_MEMBERS(projectId, currentProjectRole), () => fetchProjectMembers(workspaceSlug, projectId), { + useSWR(roleKey(PROJECT_MEMBERS(projectId, currentProjectRole)), () => fetchProjectMembers(workspaceSlug, projectId), { revalidateIfStale: false, revalidateOnFocus: false, }); // fetching project states - useSWR(PROJECT_STATES(projectId, currentProjectRole), () => fetchProjectStates(workspaceSlug, projectId), { + useSWR(roleKey(PROJECT_STATES(projectId, currentProjectRole)), () => fetchProjectStates(workspaceSlug, projectId), { revalidateIfStale: false, revalidateOnFocus: false, }); // fetching project intake state - useSWR(PROJECT_INTAKE_STATE(projectId, currentProjectRole), () => fetchProjectIntakeState(workspaceSlug, projectId), { - revalidateIfStale: false, - revalidateOnFocus: false, - }); + useSWR( + featureKey(PROJECT_INTAKE_STATE(projectId, currentProjectRole), project?.inbox_view), + () => fetchProjectIntakeState(workspaceSlug, projectId), + { revalidateIfStale: false, revalidateOnFocus: false } + ); // fetching project estimates - useSWR(PROJECT_ESTIMATES(projectId, currentProjectRole), () => getProjectEstimates(workspaceSlug, projectId), { - revalidateIfStale: false, - revalidateOnFocus: false, - }); + useSWR( + roleKey(PROJECT_ESTIMATES(projectId, currentProjectRole)), + () => getProjectEstimates(workspaceSlug, projectId), + { revalidateIfStale: false, revalidateOnFocus: false } + ); // fetching project cycles - useSWR(PROJECT_ALL_CYCLES(projectId, currentProjectRole), () => fetchAllCycles(workspaceSlug, projectId), { - revalidateIfStale: false, - revalidateOnFocus: false, - }); + useSWR( + featureKey(PROJECT_ALL_CYCLES(projectId, currentProjectRole), project?.cycle_view), + () => fetchAllCycles(workspaceSlug, projectId), + { revalidateIfStale: false, revalidateOnFocus: false } + ); // fetching project modules useSWR( - PROJECT_MODULES(projectId, currentProjectRole), + featureKey(PROJECT_MODULES(projectId, currentProjectRole), project?.module_view), async () => { await Promise.all([fetchModulesSlim(workspaceSlug, projectId), fetchModules(workspaceSlug, projectId)]); }, { revalidateIfStale: false, revalidateOnFocus: false } ); // fetching project views - useSWR(PROJECT_VIEWS(projectId, currentProjectRole), () => fetchViews(workspaceSlug, projectId), { - revalidateIfStale: false, - revalidateOnFocus: false, - }); + useSWR( + featureKey(PROJECT_VIEWS(projectId, currentProjectRole), project?.issue_views_view), + () => fetchViews(workspaceSlug, projectId), + { revalidateIfStale: false, revalidateOnFocus: false } + ); // handle join project const handleJoinProject = () => { @@ -144,6 +160,20 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP const isProjectLoading = (isParentLoading || isProjectDetailsLoading) && !projectDetailsError; + // On the work items page, start the display filters together with the project details: the layout root + // (issue-layouts/roots/project-layout-root.tsx, same SWR key) asks for them only after the project + // details are loaded, and the work items request waits for them — one network round trip less. + // Only while the children are not rendered yet (first mount, project details still loading): when they + // are, the layout root's own useSWR (a child effect, which runs first) already makes the request. + const isWorkItemsPage = !!pathname?.includes(`/projects/${projectId}/issues`); + useEffect(() => { + if (!isWorkItemsPage || !isProjectLoading) return; + void preload(`PROJECT_ISSUES_${workspaceSlug}_${projectId}`, () => + projectIssuesFilter.fetchFilters(workspaceSlug, projectId) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (isProjectLoading) return null; if (!isProjectLoading && hasPermissionToCurrentProject === false) { diff --git a/apps/web/lib/wrappers/instance-wrapper.tsx b/apps/web/lib/wrappers/instance-wrapper.tsx index c5beae4d0fd3..ac790d5063a8 100644 --- a/apps/web/lib/wrappers/instance-wrapper.tsx +++ b/apps/web/lib/wrappers/instance-wrapper.tsx @@ -5,13 +5,17 @@ */ import type { ReactNode } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; -import useSWR from "swr"; +import useSWR, { preload } from "swr"; +import { PROJECT_DETAILS } from "@plane/constants"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { InstanceNotReady, MaintenanceView } from "@/components/instance"; // hooks import { useInstance } from "@/hooks/store/use-instance"; +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; type TInstanceWrapper = { children: ReactNode; @@ -21,6 +25,34 @@ const InstanceWrapper = observer(function InstanceWrapper(props: TInstanceWrappe const { children } = props; // store const { isLoading, instance, error, fetchInstanceInfo } = useInstance(); + const { fetchCurrentUser } = useUser(); + const { fetchProjectDetails } = useProject(); + + // Ask for the current user together with the instance, not after it: AuthenticationWrapper (a child, + // rendered only once the instance is loaded) uses the preloaded result through the same SWR key. + // Safe for signed-out visitors: fetchCurrentUser asks /users/me/ without the 401 redirect and asks the + // profile, settings and workspaces only for a signed-in user. + // On a project page, the project details (what ProjectAuthWrapper waits for before it renders) start as + // soon as the user is known, instead of after the workspace wrappers; same SWR key as project-wrapper.tsx. + // Only on the first mount (a full page load). + useEffect(() => { + const quiet = (request: Promise) => (request.catch(() => undefined), request); // no unhandled rejection if unused + const user = quiet(fetchCurrentUser()); + void preload("USER_INFORMATION", () => user); + const match = window.location.pathname.match(/^\/([^/]+)\/projects\/([0-9a-f-]{36})\//); + if (match) { + const [, slug, projectId] = match; + void preload(PROJECT_DETAILS(slug, projectId), () => + quiet( + user.then((currentUser) => { + if (!currentUser?.id) throw new Error("not signed in"); + return fetchProjectDetails(slug, projectId); + }) + ) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const { isLoading: isInstanceSWRLoading, error: instanceSWRError } = useSWR( "INSTANCE_INFORMATION",