From 5e108b60eb7ac4c747719ee6e8b50c7a8bf16775 Mon Sep 17 00:00:00 2001 From: Hamidreza Nasrabadi <77030858+Ariiima@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:29:52 +0330 Subject: [PATCH 1/2] perf(web): fetch project data once and preload the work item peek - Project wrapper: fetch role-dependent project data only once the project role is known. The role is undefined for ~40 ms after mount (until the workspace's project roles arrive); every SWR key contains it, so all project data (labels, members, states, estimates, cycles, modules, views, intake, member preferences) was fetched twice on every project page load. - Project wrapper: skip cycles, modules, views and intake data when that feature is turned off for the project (the flags come with the workspace's project list). - Project wrapper: on the work items page, preload the display filters together with the project details; the layout root asked for them only after the project details had loaded. - Instance wrapper: fetch the current user together with the instance info, and on a project page the project details as soon as the user is known (signed-out visitors: /users/me/ is called without the 401 redirect, the project request is not made). - Kanban block: preload the peek overview while the pointer rests on a card (120 ms), with the same SWR key as the peek overview, so the peek opens without waiting for the request. - Kanban: 100 work items per group (was 30), as the list layout loads ungrouped. --- .../issue-layouts/kanban/base-kanban-root.tsx | 4 +- .../issues/issue-layouts/kanban/block.tsx | 24 +++++- .../roots/project-layout-root.tsx | 1 + .../layouts/auth-layout/project-wrapper.tsx | 77 +++++++++++++------ apps/web/lib/wrappers/instance-wrapper.tsx | 34 +++++++- 5 files changed, 113 insertions(+), 27 deletions(-) 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..ca4717ceca62 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(() => { @@ -79,6 +91,19 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 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 on the first mount: on client navigation the layout root's own useSWR (child effect) runs first. + const isWorkItemsPage = !!pathname?.includes(`/projects/${projectId}/issues`); + useEffect(() => { + if (!isWorkItemsPage) return; + void preload(`PROJECT_ISSUES_${workspaceSlug}_${projectId}`, () => + projectIssuesFilter.fetchFilters(workspaceSlug, projectId) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // fetching project details const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR( PROJECT_DETAILS(workspaceSlug, projectId), @@ -88,53 +113,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 = () => { 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", From 2f7760b07939f7d3ab72d1720d6d89999dd19a3e Mon Sep 17 00:00:00 2001 From: Hamidreza Nasrabadi <77030858+Ariiima@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:51:03 +0330 Subject: [PATCH 2/2] perf(web): preload the display filters only while the project is loading When the project details are cached, ProjectAuthWrapper renders the layout root at once and its useSWR already requests the filters (a child effect runs before the parent's); the preload then made a second request for the same key. Preload only while the children are not rendered yet. --- .../layouts/auth-layout/project-wrapper.tsx | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/web/layouts/auth-layout/project-wrapper.tsx b/apps/web/layouts/auth-layout/project-wrapper.tsx index ca4717ceca62..0f79397b4a88 100644 --- a/apps/web/layouts/auth-layout/project-wrapper.tsx +++ b/apps/web/layouts/auth-layout/project-wrapper.tsx @@ -91,19 +91,6 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // 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 on the first mount: on client navigation the layout root's own useSWR (child effect) runs first. - const isWorkItemsPage = !!pathname?.includes(`/projects/${projectId}/issues`); - useEffect(() => { - if (!isWorkItemsPage) return; - void preload(`PROJECT_ISSUES_${workspaceSlug}_${projectId}`, () => - projectIssuesFilter.fetchFilters(workspaceSlug, projectId) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // fetching project details const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR( PROJECT_DETAILS(workspaceSlug, projectId), @@ -173,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) {