From 328a3064bba3a75c5bc0826579c24bc1c6ae1edb Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 7 Feb 2025 17:35:06 +0000 Subject: [PATCH] perf: improve optimistic queries --- apps/web/src/components/Dropdown.tsx | 7 +- apps/web/src/pages/_app.tsx | 8 +- apps/web/src/providers/board.tsx | 105 ------------------ .../views/board/components/BoardDropdown.tsx | 3 +- .../components/DeleteBoardConfirmation.tsx | 21 ++-- .../components/DeleteListConfirmation.tsx | 32 +++--- .../src/views/board/components/Filters.tsx | 8 +- .../views/board/components/NewCardForm.tsx | 4 +- .../views/board/components/NewListForm.tsx | 53 +++++++-- .../board/components/UpdateBoardSlugForm.tsx | 20 ++-- .../board/components/VisibilityButton.tsx | 20 +++- apps/web/src/views/board/index.tsx | 59 +++++++--- .../components/DeleteCardConfirmation.tsx | 55 ++++++--- 13 files changed, 200 insertions(+), 195 deletions(-) delete mode 100644 apps/web/src/providers/board.tsx diff --git a/apps/web/src/components/Dropdown.tsx b/apps/web/src/components/Dropdown.tsx index e9a7646a..7bf315d9 100644 --- a/apps/web/src/components/Dropdown.tsx +++ b/apps/web/src/components/Dropdown.tsx @@ -4,14 +4,19 @@ import { Fragment } from "react"; export default function Dropdown({ items, children, + disabled, }: { items: { label: string; action: () => void; icon?: React.ReactNode }[]; children: React.ReactNode; + disabled?: boolean; }) { return (
- + {children}
diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 4914f333..35636c06 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -1,13 +1,11 @@ import "~/styles/globals.css"; -import { type AppType } from "next/app"; +import type { AppType } from "next/app"; import { Plus_Jakarta_Sans } from "next/font/google"; import { ModalProvider } from "~/providers/modal"; -import { BoardProvider } from "~/providers/board"; import { PopupProvider } from "~/providers/popup"; import { ThemeProvider } from "~/providers/theme"; - import { api } from "~/utils/api"; const jakarta = Plus_Jakarta_Sans({ @@ -36,9 +34,7 @@ const MyApp: AppType = ({ Component, pageProps }) => { - - - + diff --git a/apps/web/src/providers/board.tsx b/apps/web/src/providers/board.tsx deleted file mode 100644 index 5adb006c..00000000 --- a/apps/web/src/providers/board.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { ReactNode } from "react"; -import React, { createContext, useContext, useState } from "react"; - -import type { GetBoardByIdOutput, NewListInput } from "@kan/api/types"; -import { generateUID } from "@kan/shared/utils"; - -import { usePopup } from "~/providers/popup"; -import { api } from "~/utils/api"; - -interface BoardContextProps { - boardData: GetBoardByIdOutput; - setBoardData: React.Dispatch>; - addList: (params: NewListInput) => void; - removeCard: (params: { cardPublicId: string }) => void; - refetchBoard: () => Promise; -} - -const initialBoardData: GetBoardByIdOutput = { - name: "", - publicId: "", - lists: [], - labels: [], - workspace: { - publicId: "", - members: [], - }, -}; - -const BoardContext = createContext(undefined); - -export const BoardProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const utils = api.useUtils(); - const [boardData, setBoardData] = - useState(initialBoardData); - - const { showPopup } = usePopup(); - - const refetchBoard = async () => { - if (!boardData?.publicId) return; - - try { - await utils.board.byId.refetch(); - } catch (e) { - showPopup({ - header: "Error fetching board", - message: "Please try again later, or contact customer support.", - icon: "error", - }); - } - }; - - const addList = ({ name, boardPublicId }: NewListInput) => { - if (!boardData) return; - - const newList = { - publicId: generateUID(), - name, - boardId: 1, - boardPublicId, - cards: [], - index: boardData.lists.length, - }; - - const updatedLists = [...boardData.lists, newList]; - - setBoardData({ ...boardData, lists: updatedLists }); - }; - - const removeCard = ({ cardPublicId }: { cardPublicId: string }) => { - if (!boardData) return; - - const updatedLists = boardData.lists.map((list) => { - const updatedCards = list.cards.filter( - (card) => card.publicId !== cardPublicId, - ); - return { ...list, cards: updatedCards }; - }); - - setBoardData({ ...boardData, lists: updatedLists }); - }; - - return ( - - {children} - - ); -}; - -export const useBoard = () => { - const context = useContext(BoardContext); - if (context === undefined) { - throw new Error("useBoard must be used within a BoardProvider"); - } - return context; -}; diff --git a/apps/web/src/views/board/components/BoardDropdown.tsx b/apps/web/src/views/board/components/BoardDropdown.tsx index d7acc7cf..4d2dda81 100644 --- a/apps/web/src/views/board/components/BoardDropdown.tsx +++ b/apps/web/src/views/board/components/BoardDropdown.tsx @@ -3,11 +3,12 @@ import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; import { useModal } from "~/providers/modal"; -export default function BoardDropdown() { +export default function BoardDropdown({ isLoading }: { isLoading: boolean }) { const { openModal } = useModal(); return ( { - if (boardData?.publicId) + if (boardPublicId) deleteBoard.mutate({ - boardPublicId: boardData.publicId, + boardPublicId: boardPublicId, }); }; @@ -39,7 +40,9 @@ export function DeleteBoardConfirmation() { - + ); diff --git a/apps/web/src/views/board/components/DeleteListConfirmation.tsx b/apps/web/src/views/board/components/DeleteListConfirmation.tsx index 2b81ddd1..648b900d 100644 --- a/apps/web/src/views/board/components/DeleteListConfirmation.tsx +++ b/apps/web/src/views/board/components/DeleteListConfirmation.tsx @@ -1,45 +1,39 @@ import Button from "~/components/Button"; -import { useBoard } from "~/providers/board"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; interface DeleteListConfirmationProps { listPublicId: string; + queryParams: QueryParams; +} + +interface QueryParams { + boardPublicId: string; + members: string[]; + labels: string[]; } export function DeleteListConfirmation({ listPublicId, + queryParams, }: DeleteListConfirmationProps) { const utils = api.useUtils(); - const { boardData } = useBoard(); const { closeModal } = useModal(); const { showPopup } = usePopup(); - const refetchBoard = async () => { - if (boardData?.publicId) { - try { - await utils.board.byId.refetch(); - } catch (e) { - console.error(e); - } - } - }; - const deleteList = api.list.delete.useMutation({ - onSuccess: () => { - closeModal(); - return refetchBoard(); - }, - onError: async () => { - closeModal(); - await refetchBoard(); + onError: () => { showPopup({ header: "Unable to delete list", message: "Please try again later, or contact customer support.", icon: "error", }); }, + onSettled: async () => { + closeModal(); + await utils.board.byId.invalidate(queryParams); + }, }); return ( diff --git a/apps/web/src/views/board/components/Filters.tsx b/apps/web/src/views/board/components/Filters.tsx index 162a2fde..643ad5c7 100644 --- a/apps/web/src/views/board/components/Filters.tsx +++ b/apps/web/src/views/board/components/Filters.tsx @@ -62,9 +62,11 @@ interface BoardData { const Filters = ({ position = "right", boardData, + isLoading, }: { position?: "left" | "right"; boardData: BoardData | null; + isLoading: boolean; }) => { const router = useRouter(); @@ -166,7 +168,11 @@ const Filters = ({ menuSpacing="md" position={position} > - {numOfFilters > 0 && ( diff --git a/apps/web/src/views/board/components/NewCardForm.tsx b/apps/web/src/views/board/components/NewCardForm.tsx index fd64f689..32463cc1 100644 --- a/apps/web/src/views/board/components/NewCardForm.tsx +++ b/apps/web/src/views/board/components/NewCardForm.tsx @@ -86,11 +86,11 @@ export function NewCardForm({ listId: 2, description: "", labels: oldBoard.labels.filter((label) => - labelPublicIds.includes(label.publicId), + args.labelPublicIds.includes(label.publicId), ), members: oldBoard.workspace?.members.filter((member) => - memberPublicIds.includes(member.publicId), + args.memberPublicIds.includes(member.publicId), ) ?? [], _filteredLabels: labelPublicIds.map((id) => ({ publicId: id })), _filteredMembers: memberPublicIds.map((id) => ({ publicId: id })), diff --git a/apps/web/src/views/board/components/NewListForm.tsx b/apps/web/src/views/board/components/NewListForm.tsx index a6cbca67..ec642421 100644 --- a/apps/web/src/views/board/components/NewListForm.tsx +++ b/apps/web/src/views/board/components/NewListForm.tsx @@ -3,11 +3,11 @@ import { useForm } from "react-hook-form"; import { HiXMark } from "react-icons/hi2"; import type { NewListInput } from "@kan/api/types"; +import { generateUID } from "@kan/shared/utils"; import Button from "~/components/Button"; import Input from "~/components/Input"; import Toggle from "~/components/Toggle"; -import { useBoard } from "~/providers/board"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -16,11 +16,24 @@ type NewListFormInput = NewListInput & { isCreateAnotherEnabled: boolean; }; -export function NewListForm({ boardPublicId }: { boardPublicId: string }) { - const { refetchBoard, addList } = useBoard(); +interface QueryParams { + boardPublicId: string; + members: string[]; + labels: string[]; +} + +export function NewListForm({ + boardPublicId, + queryParams, +}: { + boardPublicId: string; + queryParams: QueryParams; +}) { const { closeModal } = useModal(); const { showPopup } = usePopup(); + const utils = api.useUtils(); + const { register, handleSubmit, reset, setValue, watch } = useForm({ defaultValues: { @@ -33,18 +46,41 @@ export function NewListForm({ boardPublicId }: { boardPublicId: string }) { const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const createList = api.list.create.useMutation({ - onSuccess: async () => { - await refetchBoard(); + onMutate: async (args) => { + await utils.board.byId.cancel(); + + const currentState = utils.board.byId.getData(queryParams); + + utils.board.byId.setData(queryParams, (oldBoard) => { + if (!oldBoard) return oldBoard; + + const newList = { + publicId: generateUID(), + name: args.name, + boardId: 1, + boardPublicId, + cards: [], + index: oldBoard.lists.length, + }; + + const updatedLists = [...oldBoard.lists, newList]; + + return { ...oldBoard, lists: updatedLists }; + }); + + return { previousState: currentState }; }, - onError: async () => { - closeModal(); - await refetchBoard(); + onError: (_error, _newList, context) => { + utils.board.byId.setData(queryParams, context?.previousState); showPopup({ header: "Unable to create list", message: "Please try again later, or contact customer support.", icon: "error", }); }, + onSettled: async () => { + await utils.board.byId.invalidate(queryParams); + }, }); useEffect(() => { @@ -54,7 +90,6 @@ export function NewListForm({ boardPublicId }: { boardPublicId: string }) { }, []); const onSubmit = (data: NewListInput) => { - addList(data); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); if (!isCreateAnotherEnabled) closeModal(); reset({ diff --git a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx index 82eb5365..24d2d553 100644 --- a/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx +++ b/apps/web/src/views/board/components/UpdateBoardSlugForm.tsx @@ -6,7 +6,6 @@ import { z } from "zod"; import Button from "~/components/Button"; import Input from "~/components/Input"; -import { useBoard } from "~/providers/board"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -25,18 +24,26 @@ const schema = z.object({ type FormValues = z.infer; +interface QueryParams { + boardPublicId: string; + members: string[]; + labels: string[]; +} + export function UpdateBoardSlugForm({ boardPublicId, workspaceSlug, boardSlug, + queryParams, }: { boardPublicId: string; workspaceSlug: string; boardSlug: string; + queryParams: QueryParams; }) { const { closeModal } = useModal(); const { showPopup } = usePopup(); - const { refetchBoard } = useBoard(); + const utils = api.useUtils(); const { register, @@ -51,18 +58,17 @@ export function UpdateBoardSlugForm({ }); const updateBoardSlug = api.board.update.useMutation({ - onSuccess: async () => { - await refetchBoard(); - closeModal(); - }, onError: () => { - closeModal(); showPopup({ header: "Unable to update board URL", message: "Please try again later, or contact customer support.", icon: "error", }); }, + onSettled: async () => { + closeModal(); + await utils.board.byId.invalidate(queryParams); + }, }); useEffect(() => { diff --git a/apps/web/src/views/board/components/VisibilityButton.tsx b/apps/web/src/views/board/components/VisibilityButton.tsx index 1d52fbd4..b1b556f2 100644 --- a/apps/web/src/views/board/components/VisibilityButton.tsx +++ b/apps/web/src/views/board/components/VisibilityButton.tsx @@ -2,19 +2,28 @@ import { useEffect, useState } from "react"; import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2"; import Button from "~/components/Button"; -import { useBoard } from "~/providers/board"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; +interface QueryParams { + boardPublicId: string; + members: string[]; + labels: string[]; +} + const VisibilityButton = ({ visibility, boardPublicId, + queryParams, + isLoading, }: { visibility: "public" | "private"; boardPublicId: string; + queryParams: QueryParams; + isLoading: boolean; }) => { - const { refetchBoard } = useBoard(); const { showPopup } = usePopup(); + const utils = api.useUtils(); const [stateVisibility, setStateVisibility] = useState<"public" | "private">( visibility, ); @@ -26,8 +35,7 @@ const VisibilityButton = ({ const isPublic = stateVisibility === "public"; const updateBoardVisibility = api.board.update.useMutation({ - onSuccess: async () => { - await refetchBoard(); + onSuccess: () => { setStateVisibility(isPublic ? "private" : "public"); }, onError: () => { @@ -37,6 +45,9 @@ const VisibilityButton = ({ icon: "error", }); }, + onSettled: async () => { + await utils.board.byId.invalidate(queryParams); + }, }); const handleUpdateBoardVisibility = () => { @@ -52,6 +63,7 @@ const VisibilityButton = ({ onClick={handleUpdateBoardVisibility} iconLeft={isPublic ? : } isLoading={updateBoardVisibility.isPending} + disabled={isLoading} > {isPublic ? "Public" : "Private"} diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 283ce5c8..6f35e55e 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -42,6 +42,7 @@ export default function BoardPage() { const { openModal, modalContentType } = useModal(); const [selectedPublicListId, setSelectedPublicListId] = useState(""); + const [isInitialLoading, setIsInitialLoading] = useState(true); const boardId = params?.boardId.length ? params.boardId[0] : null; @@ -70,11 +71,19 @@ export default function BoardPage() { const { data: boardData, isSuccess, - isLoading, + isLoading: isQueryLoading, } = api.board.byId.useQuery(queryParams, { enabled: !!boardId, }); + useEffect(() => { + if (boardId) { + setIsInitialLoading(false); + } + }, [boardId]); + + const isLoading = isInitialLoading || isQueryLoading; + const updateListMutation = api.list.reorder.useMutation({ onMutate: async (args) => { await utils.board.byId.cancel(); @@ -166,8 +175,6 @@ export default function BoardPage() { } }, [isSuccess, boardData, setValue]); - if (!boardId || !boardData) return <>; - const openNewListForm = (publicBoardId: string) => { openModal("NEW_LIST"); setSelectedPublicListId(publicBoardId); @@ -205,7 +212,7 @@ export default function BoardPage() { return ( <>
@@ -231,10 +238,16 @@ export default function BoardPage() {
+ - - +
@@ -271,7 +287,7 @@ export default function BoardPage() { {...provided.droppableProps} >
- {boardData.lists.map((list, index) => ( + {boardData?.lists.map((list, index) => ( - {modalContentType === "DELETE_BOARD" && } + {modalContentType === "DELETE_BOARD" && ( + + )} {modalContentType === "DELETE_LIST" && ( - + )} {modalContentType === "NEW_CARD" && ( )} {modalContentType === "NEW_LIST" && ( - + )} {modalContentType === "NEW_WORKSPACE" && } {modalContentType === "UPDATE_BOARD_SLUG" && ( )} diff --git a/apps/web/src/views/card/components/DeleteCardConfirmation.tsx b/apps/web/src/views/card/components/DeleteCardConfirmation.tsx index d29484cc..46201d9e 100644 --- a/apps/web/src/views/card/components/DeleteCardConfirmation.tsx +++ b/apps/web/src/views/card/components/DeleteCardConfirmation.tsx @@ -1,6 +1,6 @@ import { useRouter } from "next/navigation"; -import { useBoard } from "~/providers/board"; +import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -15,26 +15,53 @@ export function DeleteCardConfirmation({ boardPublicId, }: DeleteCardConfirmationProps) { const { closeModal } = useModal(); + const utils = api.useUtils(); const router = useRouter(); - const { removeCard, refetchBoard } = useBoard(); const { showPopup } = usePopup(); + const queryParams = { + boardPublicId, + }; + const deleteCardMutation = api.card.delete.useMutation({ - onSuccess: () => refetchBoard(), - onError: () => + onMutate: async (args) => { + await utils.board.byId.cancel(); + + const currentState = utils.board.byId.getData(queryParams); + + utils.board.byId.setData(queryParams, (oldBoard) => { + if (!oldBoard) return oldBoard; + + const updatedLists = oldBoard.lists.map((list) => { + const updatedCards = list.cards.filter( + (card) => card.publicId !== args.cardPublicId, + ); + return { ...list, cards: updatedCards }; + }); + + return { ...oldBoard, lists: updatedLists }; + }); + + return { previousState: currentState }; + }, + onError: (_error, _newList, context) => { + utils.board.byId.setData(queryParams, context?.previousState); showPopup({ - header: "Error deleting card", + header: "Unable to delete card", message: "Please try again later, or contact customer support.", icon: "error", - }), + }); + }, + onSuccess: () => { + router.push(`/boards/${boardPublicId}`); + }, + onSettled: async () => { + closeModal(); + await utils.board.byId.invalidate(queryParams); + }, }); const handleDeleteCard = () => { - removeCard({ - cardPublicId, - }); - closeModal(); - router.push(`/boards/${boardPublicId}`); deleteCardMutation.mutate({ cardPublicId, }); @@ -57,12 +84,12 @@ export function DeleteCardConfirmation({ > Cancel - +
);