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.
This commit is contained in:
Thomas Camlong committed 2026-05-25 11:01:07 +02:00
1 parent 689da2fb91
commit 884fde245b
11 files changed
+10 -145

No files matched your search

@@ -6,7 +6,4 @@ export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
},
getBoardName() {
return undefined;
},
});
@@ -6,7 +6,4 @@ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
getBoardName({ name }) {
return name;
},
});
@@ -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<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>;
getBoardName: (params: TParams) => string | undefined;
}
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
getBoardName,
}: Props<TParams>) => {
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
getInitialBoardAsync: getBoardForParams,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
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 = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
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 = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
@@ -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() {
@@ -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(), {
@@ -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);
},
});
};
@@ -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,
},
)();
}
@@ -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");
}
+1 -2
View File
@@ -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",
-1
View File
@@ -1 +0,0 @@
export { getSharedBoardWithWhereAsync } from "./board";
-87
View File
@@ -1527,93 +1527,6 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
return [...itemElements, ...sectionElements];
};
/**
* Fetches the board structure without per-user data (collapse states, permissions).
* This result is safe to cache because it's identical for all viewers.
*/
export const getSharedBoardWithWhereAsync = async (db: Database, where: SQL<unknown>) => {
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<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
};
};
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),