From 2c388bd4494a7b1756557cd9545a37d706e3dab5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 18 May 2026 14:48:43 +0200 Subject: [PATCH 1/4] perf(boards): parallelize board page data fetching Board fetch + auth() now run concurrently via Promise.all instead of sequentially. Widget prefetches for all kinds also run in parallel alongside integration permission loading. --- .../[locale]/boards/(content)/_creator.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 64c9289e3..3314dee3c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -41,13 +41,11 @@ export const createBoardContentPage = >( }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { - const session = await auth(); - const integrations = await getIntegrationsWithPermissionsAsync(session); - - const board = await getInitialBoard(await params); + const resolvedParams = await params; const queryClient = getQueryClient(); - // Prefetch item data + const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); + const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); if (existing) { @@ -58,17 +56,20 @@ export const createBoardContentPage = >( return acc; }, new Map()); - for (const [kind, items] of itemsMap) { - await prefetchForKindAsync(kind, queryClient, items).catch((error) => { - logger.error( - new ErrorWithMetadata( - "Failed to prefetch widget", - { widgetKind: kind, itemCount: items.length }, - { cause: error }, - ), - ); - }); - } + const [integrations] = await Promise.all([ + getIntegrationsWithPermissionsAsync(session), + ...Array.from(itemsMap).map(([kind, items]) => + prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error( + new ErrorWithMetadata( + "Failed to prefetch widget", + { widgetKind: kind, itemCount: items.length }, + { cause: error }, + ), + ); + }), + ), + ]); return ( From 22af9e2c3e6ec0e918eed8b008d971e9eacea167 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 18 May 2026 14:51:44 +0200 Subject: [PATCH 2/4] feat(boards): cache named board data with tag-based invalidation Named boards are now served from Next.js unstable_cache (1h TTL, tag `board-{name}`). A new `getSharedBoardWithWhereAsync` helper fetches the board without per-user data so the result is safe to share across requests. Cache is busted via `invalidateBoardCacheAsync` (revalidateTag + revalidatePath) from board save, layout save, and partial settings mutations. --- .../boards/(content)/(home)/_definition.ts | 3 + .../(content)/[name]/(board)/_definition.tsx | 3 + .../[locale]/boards/(content)/_creator.tsx | 16 +++- .../boards/(content)/_header-actions.tsx | 5 +- .../boards/[name]/settings/_layout.tsx | 2 + .../boards/[name]/settings/_shared.tsx | 2 + .../src/app/[locale]/boards/_cached-board.ts | 21 +++++ .../boards/_invalidate-board-cache.ts | 8 ++ packages/api/package.json | 3 +- packages/api/src/router/board-exports.ts | 1 + packages/api/src/router/board.ts | 87 +++++++++++++++++++ 11 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/_cached-board.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts create mode 100644 packages/api/src/router/board-exports.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..342e1144c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { return await api.board.getHomeBoard(); }, + getBoardName() { + return undefined; + }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..00012ea12 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { return await api.board.getBoardByName({ name }); }, + getBoardName({ name }) { + return name; + }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 3314dee3c..8cb569ca4 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -18,6 +18,7 @@ import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { createMetaTitle } from "~/metadata"; +import { getCachedBoardByName } from "../_cached-board"; import { createBoardLayout } from "../_layout-creator"; import type { Board, Item } from "../_types"; import { DynamicClientBoard } from "./_dynamic-client"; @@ -29,10 +30,12 @@ export type Params = Record; interface Props { getInitialBoardAsync: (params: TParams) => Promise; + getBoardName: (params: TParams) => string | undefined; } export const createBoardContentPage = >({ getInitialBoardAsync: getInitialBoard, + getBoardName, }: Props) => { return { layout: createBoardLayout({ @@ -42,9 +45,13 @@ export const createBoardContentPage = >( // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { const resolvedParams = await params; + const boardName = getBoardName(resolvedParams); const queryClient = getQueryClient(); - const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); + const [board, session] = await Promise.all([ + boardName ? getCachedBoardByName(boardName) : getInitialBoard(resolvedParams), + auth(), + ]); const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); @@ -81,7 +88,12 @@ export const createBoardContentPage = >( }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { try { - const board = await getInitialBoard(await params); + const resolvedParams = await params; + const boardName = getBoardName(resolvedParams); + + const board = boardName + ? await getCachedBoardByName(boardName) + : await getInitialBoard(resolvedParams); const t = await getI18n(); return { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index f1185430c..f0a544428 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -21,7 +21,6 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; -import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; import { useConfirmModal, useModalAction } from "@homarr/modals"; @@ -30,6 +29,8 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; +import { invalidateBoardCacheAsync } from "../_invalidate-board-cache"; + import { useItemActions } from "~/components/board/items/item-actions"; import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; @@ -153,7 +154,7 @@ const EditModeMenu = () => { message: t("notification.success.message"), }); void utils.board.getBoardByName.invalidate({ name: board.name }); - void revalidatePathActionAsync(`/boards/${board.name}`); + void invalidateBoardCacheAsync(board.name); close(); }, onError() { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 97a576a30..a2688c5b2 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema } from "@homarr/validation/board"; import type { Board } from "../../_types"; +import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; interface Props { board: Board; @@ -20,6 +21,7 @@ export const LayoutSettingsContent = ({ board }: Props) => { onSettled() { void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getHomeBoard.invalidate(); + void invalidateBoardCacheAsync(board.name); }, }); const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index 9fa888be2..85899cc36 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -1,6 +1,7 @@ import { clientApi } from "@homarr/api/client"; import type { Board } from "../../_types"; +import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; export const useSavePartialSettingsMutation = (board: Board) => { const utils = clientApi.useUtils(); @@ -8,6 +9,7 @@ export const useSavePartialSettingsMutation = (board: Board) => { onSettled() { void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getHomeBoard.invalidate(); + void invalidateBoardCacheAsync(board.name); }, }); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_cached-board.ts b/apps/nextjs/src/app/[locale]/boards/_cached-board.ts new file mode 100644 index 000000000..f08b16f90 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_cached-board.ts @@ -0,0 +1,21 @@ +import { unstable_cache } from "next/cache"; + +import { db, eq, sql } from "@homarr/db"; +import { boards } from "@homarr/db/schema"; + +import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board"; + +export function getCachedBoardByName(name: string) { + const tag = `board-${name.toLowerCase()}`; + return unstable_cache( + async () => { + const where = eq(sql`UPPER(${boards.name})`, name.toUpperCase()); + return await getSharedBoardWithWhereAsync(db, where); + }, + ["board-by-name", name.toLowerCase()], + { + tags: [tag], + revalidate: 3600, + }, + )(); +} diff --git a/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts b/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts new file mode 100644 index 000000000..11c61efbd --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts @@ -0,0 +1,8 @@ +"use server"; + +import { revalidatePath, revalidateTag } from "next/cache"; + +export async function invalidateBoardCacheAsync(boardName: string) { + revalidateTag(`board-${boardName.toLowerCase()}`); + revalidatePath(`/boards/${boardName}`, "page"); +} diff --git a/packages/api/package.json b/packages/api/package.json index 1c137c154..5d5c1b5af 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,7 +10,8 @@ "./server": "./src/server.ts", "./websocket": "./src/websocket.ts", "./shared": "./src/shared.ts", - "./open-api": "./src/open-api.ts" + "./open-api": "./src/open-api.ts", + "./router/board": "./src/router/board-exports.ts" }, "main": "./index.ts", "types": "./index.ts", diff --git a/packages/api/src/router/board-exports.ts b/packages/api/src/router/board-exports.ts new file mode 100644 index 000000000..b0e79e272 --- /dev/null +++ b/packages/api/src/router/board-exports.ts @@ -0,0 +1 @@ +export { getSharedBoardWithWhereAsync } from "./board"; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 495d410c7..f5aca9e42 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1527,6 +1527,93 @@ const getElementsForLayout = (board: Awaited) => { + const board = await db.query.boards.findFirst({ + where, + with: { + creator: { + columns: { + id: true, + name: true, + image: true, + email: true, + }, + }, + sections: { + with: { + layouts: true, + }, + }, + items: { + with: { + integrations: { + with: { + integration: true, + }, + }, + layouts: true, + }, + }, + layouts: true, + }, + }); + + if (!board) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + const { sections, items, layouts, ...otherBoardProperties } = board; + + return { + ...otherBoardProperties, + userPermissions: [] as { permission: string }[], + groupPermissions: [] as unknown[], + layouts: layouts + .map(({ boardId: _, ...layout }) => layout) + .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), + sections: sections.map((section) => + parseSection({ + ...section, + xOffset: section.xOffset, + yOffset: section.yOffset, + options: superjson.parse(section.options ?? emptySuperJSON), + layouts: section.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + parentSectionId: layout.parentSectionId, + layoutId: layout.layoutId, + })), + collapsed: false, + }), + ), + items: items.map(({ integrations: itemIntegrations, ...item }) => + parseItem({ + ...item, + layouts: item.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + layoutId: layout.layoutId, + sectionId: layout.sectionId, + })), + integrationIds: itemIntegrations.map((item) => item.integration.id), + advancedOptions: superjson.parse(item.advancedOptions), + options: superjson.parse>(item.options), + }), + ), + }; +}; + const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => { const groupsOfCurrentUser = await db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId ?? ""), From 689da2fb9112d3d0bd551d33ba4db1db0e714e8f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 18 May 2026 15:01:01 +0200 Subject: [PATCH 3/4] fix: format files and fix revalidateTag signature for Next 16 - Run prettier on all modified files - Add required cache profile arg to revalidateTag - Suppress require-await for server action (must be async) --- apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx | 4 +--- .../src/app/[locale]/boards/(content)/_header-actions.tsx | 3 +-- .../src/app/[locale]/boards/[name]/settings/_layout.tsx | 2 +- .../src/app/[locale]/boards/[name]/settings/_shared.tsx | 2 +- apps/nextjs/src/app/[locale]/boards/_cached-board.ts | 3 +-- .../nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts | 3 ++- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 8cb569ca4..e04026d26 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -91,9 +91,7 @@ export const createBoardContentPage = >( const resolvedParams = await params; const boardName = getBoardName(resolvedParams); - const board = boardName - ? await getCachedBoardByName(boardName) - : await getInitialBoard(resolvedParams); + const board = boardName ? await getCachedBoardByName(boardName) : await getInitialBoard(resolvedParams); const t = await getI18n(); return { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index f0a544428..973ac3873 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -29,8 +29,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { invalidateBoardCacheAsync } from "../_invalidate-board-cache"; - import { useItemActions } from "~/components/board/items/item-actions"; import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; @@ -38,6 +36,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { HeaderButton } from "~/components/layout/header/button"; +import { invalidateBoardCacheAsync } from "../_invalidate-board-cache"; export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index a2688c5b2..ea2387ed2 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -8,8 +8,8 @@ import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; +import type { Board } from "../../_types"; interface Props { board: Board; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index 85899cc36..bd1e1aa9f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -1,7 +1,7 @@ import { clientApi } from "@homarr/api/client"; -import type { Board } from "../../_types"; import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; +import type { Board } from "../../_types"; export const useSavePartialSettingsMutation = (board: Board) => { const utils = clientApi.useUtils(); diff --git a/apps/nextjs/src/app/[locale]/boards/_cached-board.ts b/apps/nextjs/src/app/[locale]/boards/_cached-board.ts index f08b16f90..4b9176ad0 100644 --- a/apps/nextjs/src/app/[locale]/boards/_cached-board.ts +++ b/apps/nextjs/src/app/[locale]/boards/_cached-board.ts @@ -1,10 +1,9 @@ import { unstable_cache } from "next/cache"; +import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board"; import { db, eq, sql } from "@homarr/db"; import { boards } from "@homarr/db/schema"; -import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board"; - export function getCachedBoardByName(name: string) { const tag = `board-${name.toLowerCase()}`; return unstable_cache( diff --git a/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts b/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts index 11c61efbd..e10906431 100644 --- a/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts +++ b/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts @@ -2,7 +2,8 @@ import { revalidatePath, revalidateTag } from "next/cache"; +// eslint-disable-next-line @typescript-eslint/require-await export async function invalidateBoardCacheAsync(boardName: string) { - revalidateTag(`board-${boardName.toLowerCase()}`); + revalidateTag(`board-${boardName.toLowerCase()}`, { expire: 3600 }); revalidatePath(`/boards/${boardName}`, "page"); } From 884fde245bc752420b5a3ef55cfc3e3d31244ef5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 25 May 2026 11:01:07 +0200 Subject: [PATCH 4/4] fix(boards): replace global cache with React cache() per-request dedup The unstable_cache approach bypassed throwIfActionForbiddenAsync and dropped user-specific data (permissions, collapse states). Use React cache() to deduplicate getInitialBoard within a single request instead, preserving auth checks and all user data while reducing DB calls from 9 to 3 per board page load. --- .../boards/(content)/(home)/_definition.ts | 3 - .../(content)/[name]/(board)/_definition.tsx | 3 - .../[locale]/boards/(content)/_creator.tsx | 21 ++--- .../boards/(content)/_header-actions.tsx | 4 +- .../boards/[name]/settings/_layout.tsx | 2 - .../boards/[name]/settings/_shared.tsx | 2 - .../src/app/[locale]/boards/_cached-board.ts | 20 ----- .../boards/_invalidate-board-cache.ts | 9 -- packages/api/package.json | 3 +- packages/api/src/router/board-exports.ts | 1 - packages/api/src/router/board.ts | 87 ------------------- 11 files changed, 10 insertions(+), 145 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/boards/_cached-board.ts delete mode 100644 apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts delete mode 100644 packages/api/src/router/board-exports.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 342e1144c..2357d7424 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -6,7 +6,4 @@ export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { return await api.board.getHomeBoard(); }, - getBoardName() { - return undefined; - }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index 00012ea12..d3b65a5e9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -6,7 +6,4 @@ export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { return await api.board.getBoardByName({ name }); }, - getBoardName({ name }) { - return name; - }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index e04026d26..106f1f562 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { Metadata } from "next"; import { TRPCError } from "@trpc/server"; @@ -18,7 +19,6 @@ import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { createMetaTitle } from "~/metadata"; -import { getCachedBoardByName } from "../_cached-board"; import { createBoardLayout } from "../_layout-creator"; import type { Board, Item } from "../_types"; import { DynamicClientBoard } from "./_dynamic-client"; @@ -30,28 +30,25 @@ export type Params = Record; interface Props { getInitialBoardAsync: (params: TParams) => Promise; - getBoardName: (params: TParams) => string | undefined; } export const createBoardContentPage = >({ getInitialBoardAsync: getInitialBoard, - getBoardName, }: Props) => { + const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params)); + const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params); + return { layout: createBoardLayout({ headerActions: , - getInitialBoardAsync: getInitialBoard, + getInitialBoardAsync: getBoardForParams, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { const resolvedParams = await params; - const boardName = getBoardName(resolvedParams); const queryClient = getQueryClient(); - const [board, session] = await Promise.all([ - boardName ? getCachedBoardByName(boardName) : getInitialBoard(resolvedParams), - auth(), - ]); + const [board, session] = await Promise.all([getBoardForParams(resolvedParams), auth()]); const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); @@ -88,10 +85,7 @@ export const createBoardContentPage = >( }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { try { - const resolvedParams = await params; - const boardName = getBoardName(resolvedParams); - - const board = boardName ? await getCachedBoardByName(boardName) : await getInitialBoard(resolvedParams); + const board = await getBoardForParams(await params); const t = await getI18n(); return { @@ -107,7 +101,6 @@ export const createBoardContentPage = >( }, }; } catch (error) { - // Ignore not found errors and return empty metadata if (error instanceof TRPCError && error.code === "NOT_FOUND") { return {}; } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 973ac3873..f1185430c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -21,6 +21,7 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; +import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; import { useConfirmModal, useModalAction } from "@homarr/modals"; @@ -36,7 +37,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { HeaderButton } from "~/components/layout/header/button"; -import { invalidateBoardCacheAsync } from "../_invalidate-board-cache"; export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); @@ -153,7 +153,7 @@ const EditModeMenu = () => { message: t("notification.success.message"), }); void utils.board.getBoardByName.invalidate({ name: board.name }); - void invalidateBoardCacheAsync(board.name); + void revalidatePathActionAsync(`/boards/${board.name}`); close(); }, onError() { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index ea2387ed2..97a576a30 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -8,7 +8,6 @@ import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema } from "@homarr/validation/board"; -import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; import type { Board } from "../../_types"; interface Props { @@ -21,7 +20,6 @@ export const LayoutSettingsContent = ({ board }: Props) => { onSettled() { void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getHomeBoard.invalidate(); - void invalidateBoardCacheAsync(board.name); }, }); const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index bd1e1aa9f..9fa888be2 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -1,6 +1,5 @@ import { clientApi } from "@homarr/api/client"; -import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache"; import type { Board } from "../../_types"; export const useSavePartialSettingsMutation = (board: Board) => { @@ -9,7 +8,6 @@ export const useSavePartialSettingsMutation = (board: Board) => { onSettled() { void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getHomeBoard.invalidate(); - void invalidateBoardCacheAsync(board.name); }, }); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_cached-board.ts b/apps/nextjs/src/app/[locale]/boards/_cached-board.ts deleted file mode 100644 index 4b9176ad0..000000000 --- a/apps/nextjs/src/app/[locale]/boards/_cached-board.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { unstable_cache } from "next/cache"; - -import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board"; -import { db, eq, sql } from "@homarr/db"; -import { boards } from "@homarr/db/schema"; - -export function getCachedBoardByName(name: string) { - const tag = `board-${name.toLowerCase()}`; - return unstable_cache( - async () => { - const where = eq(sql`UPPER(${boards.name})`, name.toUpperCase()); - return await getSharedBoardWithWhereAsync(db, where); - }, - ["board-by-name", name.toLowerCase()], - { - tags: [tag], - revalidate: 3600, - }, - )(); -} diff --git a/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts b/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts deleted file mode 100644 index e10906431..000000000 --- a/apps/nextjs/src/app/[locale]/boards/_invalidate-board-cache.ts +++ /dev/null @@ -1,9 +0,0 @@ -"use server"; - -import { revalidatePath, revalidateTag } from "next/cache"; - -// eslint-disable-next-line @typescript-eslint/require-await -export async function invalidateBoardCacheAsync(boardName: string) { - revalidateTag(`board-${boardName.toLowerCase()}`, { expire: 3600 }); - revalidatePath(`/boards/${boardName}`, "page"); -} diff --git a/packages/api/package.json b/packages/api/package.json index 5d5c1b5af..1c137c154 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,8 +10,7 @@ "./server": "./src/server.ts", "./websocket": "./src/websocket.ts", "./shared": "./src/shared.ts", - "./open-api": "./src/open-api.ts", - "./router/board": "./src/router/board-exports.ts" + "./open-api": "./src/open-api.ts" }, "main": "./index.ts", "types": "./index.ts", diff --git a/packages/api/src/router/board-exports.ts b/packages/api/src/router/board-exports.ts deleted file mode 100644 index b0e79e272..000000000 --- a/packages/api/src/router/board-exports.ts +++ /dev/null @@ -1 +0,0 @@ -export { getSharedBoardWithWhereAsync } from "./board"; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index f5aca9e42..495d410c7 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1527,93 +1527,6 @@ const getElementsForLayout = (board: Awaited) => { - const board = await db.query.boards.findFirst({ - where, - with: { - creator: { - columns: { - id: true, - name: true, - image: true, - email: true, - }, - }, - sections: { - with: { - layouts: true, - }, - }, - items: { - with: { - integrations: { - with: { - integration: true, - }, - }, - layouts: true, - }, - }, - layouts: true, - }, - }); - - if (!board) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Board not found", - }); - } - - const { sections, items, layouts, ...otherBoardProperties } = board; - - return { - ...otherBoardProperties, - userPermissions: [] as { permission: string }[], - groupPermissions: [] as unknown[], - layouts: layouts - .map(({ boardId: _, ...layout }) => layout) - .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), - sections: sections.map((section) => - parseSection({ - ...section, - xOffset: section.xOffset, - yOffset: section.yOffset, - options: superjson.parse(section.options ?? emptySuperJSON), - layouts: section.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - parentSectionId: layout.parentSectionId, - layoutId: layout.layoutId, - })), - collapsed: false, - }), - ), - items: items.map(({ integrations: itemIntegrations, ...item }) => - parseItem({ - ...item, - layouts: item.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - layoutId: layout.layoutId, - sectionId: layout.sectionId, - })), - integrationIds: itemIntegrations.map((item) => item.integration.id), - advancedOptions: superjson.parse(item.advancedOptions), - options: superjson.parse>(item.options), - }), - ), - }; -}; - const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => { const groupsOfCurrentUser = await db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId ?? ""),