From bc41125b7f19e50b2c913a36f5d7712e37a002f3 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 8 Aug 2026 18:24:38 +0200 Subject: [PATCH] fix: guard recursive section rendering --- .../board/sections/dynamic-section.tsx | 9 ++++++++- .../board/sections/gridstack/gridstack.spec.ts | 11 +++++++++++ .../board/sections/gridstack/gridstack.tsx | 16 +++++++++++++--- .../components/board/sections/section-context.ts | 1 + 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx index 8bb0a52c3..a8d614f98 100644 --- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx @@ -6,6 +6,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import type { DynamicSectionItem } from "~/app/[locale]/boards/_types"; import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu"; import { GridStack } from "./gridstack/gridstack"; +import { useSectionContext } from "./section-context"; import classes from "./item.module.css"; interface Props { @@ -15,6 +16,7 @@ interface Props { export const BoardDynamicSection = ({ section }: Props) => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); + const { ancestorSectionIds } = useSectionContext(); const options = section.options; return ( @@ -53,7 +55,12 @@ export const BoardDynamicSection = ({ section }: Props) => { )} {/* Use unique key by layout to reinitialize gridstack */} - + diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts b/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts new file mode 100644 index 000000000..3df5c1a38 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "vitest"; + +import { filterRecursiveInnerSections } from "./gridstack"; + +describe("filterRecursiveInnerSections", () => { + test("removes sections that would recursively render an ancestor", () => { + const innerSections = [{ id: "safe" }, { id: "parent" }, { id: "root" }]; + + expect(filterRecursiveInnerSections(innerSections, new Set(["root", "parent"]))).toEqual([{ id: "safe" }]); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx index e851e5c2a..1d0090625 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -12,16 +12,21 @@ import { useGridstack } from "./use-gridstack"; interface Props extends BoxProps { section: Exclude | DynamicSectionItem; + ancestorSectionIds?: ReadonlySet; } -export const GridStack = ({ section, ...props }: Props) => { +export const GridStack = ({ section, ancestorSectionIds = new Set(), ...props }: Props) => { const { items, innerSections } = useSectionItems(section.id); - const itemIds = [...items, ...innerSections].map((item) => item.id); + const nextAncestorSectionIds = new Set(ancestorSectionIds).add(section.id); + const safeInnerSections = filterRecursiveInnerSections(innerSections, nextAncestorSectionIds); + const itemIds = [...items, ...safeInnerSections].map((item) => item.id); const { refs } = useGridstack(section, itemIds); return ( - + { ); }; + +export const filterRecursiveInnerSections = ( + innerSections: T[], + ancestorSectionIds: ReadonlySet, +) => innerSections.filter((innerSection) => !ancestorSectionIds.has(innerSection.id)); diff --git a/apps/nextjs/src/components/board/sections/section-context.ts b/apps/nextjs/src/components/board/sections/section-context.ts index c01d05d7c..583a07d48 100644 --- a/apps/nextjs/src/components/board/sections/section-context.ts +++ b/apps/nextjs/src/components/board/sections/section-context.ts @@ -5,6 +5,7 @@ import type { UseGridstackRefs } from "./gridstack/use-gridstack"; interface SectionContextProps { section: Exclude | DynamicSectionItem; + ancestorSectionIds: ReadonlySet; innerSections: DynamicSectionItem[]; items: SectionItem[]; refs: UseGridstackRefs;