Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
]

return (
<div id="report-root" className="flex h-full flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
<div id="report-root" className="flex h-full flex-col overflow-y-scroll p-6 [scrollbar-gutter:stable]">
<div className="mb-6 hidden print:block">
<h1 className="text-xl font-semibold text-foreground">DataShield Security Report</h1>
<p className="text-sm text-muted-foreground">
Expand Down
44 changes: 26 additions & 18 deletions src/components/reports/ReportCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
const [hidden, setHidden] = useState<string[]>([])
const [sectionsMenuOpen, setSectionsMenuOpen] = useState(false)
const [minHeights, setMinHeights] = useState<Record<string, number>>({})
// Bumped to force a re-measure even when widths are unchanged (e.g. Reset).
const [measureNonce, setMeasureNonce] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const contentEls = useRef(new Map<string, HTMLDivElement>())
Expand Down Expand Up @@ -96,8 +98,12 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
// fitHeights effect. An unrounded value changes every render and feeds an
// endless update loop (Maximum update depth exceeded).
const ro = new ResizeObserver((entries) => {
const w = Math.round(entries[0].contentRect.width)
setContainerW((prev) => (prev === w ? prev : w))
// Floor (never round up): the grid is rendered at width=containerW, and a
// value 1px wider than the container overflows horizontally, toggling a
// scrollbar that shrinks the container, which feeds an endless loop.
const w = Math.floor(entries[0].contentRect.width)
// Ignore sub-2px wobble so a 1px layout ping-pong cannot drive a loop.
setContainerW((prev) => (Math.abs(prev - w) < 2 ? prev : w))
})
ro.observe(el)
return () => ro.disconnect()
Expand Down Expand Up @@ -144,17 +150,16 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
})
}, [])

// Content height only changes with width (container queries reflow on the
// tile width). Re-measure when widths change, never via a continuous
// ResizeObserver: height is intentionally NOT a dependency, so fitHeights
// setting h cannot retrigger this effect. That makes the auto-fit
// loop-free by construction (previous versions oscillated forever).
const widthSig = layout.map((l) => `${l.i}:${l.w}`).join("|")
const hiddenSig = hidden.join(",")
useEffect(() => {
const ro = new ResizeObserver(() => fitHeights())
// Observe the intrinsic probe, not the cell-sized node: the cell height is
// driven by fitHeights, so observing it would feed an infinite loop.
contentEls.current.forEach((node) => {
const probe = node.querySelector("[data-measure]")
if (probe) ro.observe(probe)
})
fitHeights()
return () => ro.disconnect()
}, [fitHeights, hidden, containerW, sections.length])
}, [fitHeights, widthSig, containerW, hiddenSig, sections.length, measureNonce])

useEffect(() => {
if (!sectionsMenuOpen) return
Expand Down Expand Up @@ -192,12 +197,10 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
// while still allowing manual grow. Width has no reliable intrinsic min
// (content reflows), so it stays freely resizable down to MIN_W.
const floorH = Math.max(MIN_H, minHeights[s.id] ?? MIN_H)
return {
...base,
minH: floorH,
minW: MIN_W,
h: Math.max(base.h, floorH),
}
// h comes straight from layout state (fitHeights keeps it at the content
// floor); do not override it here, or the rendered h differs from stored h
// and RGL keeps echoing the difference back through onLayoutChange.
return { ...base, minH: floorH, minW: MIN_W }
})

const onLayoutChange = (current: RglItem[]) => {
Expand Down Expand Up @@ -234,6 +237,11 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
const l = buildDefaultLayout(sections)
setLayout(l)
setHidden([])
// Drop stale content-fit floors (they may have been measured at a different
// zoom/width) and force a fresh measure, otherwise default heights can be
// wrong and tiles overlap.
setMinHeights({})
setMeasureNonce((n) => n + 1)
persist({ layout: l, hidden: [] })
}

Expand Down Expand Up @@ -331,7 +339,7 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
if (node) contentEls.current.set(s.id, node)
else contentEls.current.delete(s.id)
}}
className="h-full overflow-auto"
className="h-full overflow-hidden"
>
{s.content}
</div>
Expand Down
Loading