fix(board): restore layouts after column changes (#6979)

* fix: preserve geometry across layout resizing

* style: format layout migration snapshots
This commit is contained in:
Thomas authored and GitHub committed 2026-10-03 22:32:49 +02:00
1 parent eec910aabb
commit e048ae9cd7
14 files changed
+5567 -10

No files matched your search

@@ -42,6 +42,11 @@ Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scale
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
current Base layout.
Changing column or sidebar counts keeps a saved source arrangement while its generated positions remain unedited.
Widening back restores that arrangement, including Container sizes. Moving, resizing, adding, or removing content
starts a new source arrangement on the next layout resize. This cannot restore positions lost before this behavior
was introduced; widen the affected Container before moving widgets into its right-hand columns.
### Containers and rails
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
+37 -5
View File
@@ -4,7 +4,12 @@ import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId, generateResponsiveGridFor } from "@homarr/common";
import {
createId,
generateResponsiveGridFor,
getLayoutResizeSource,
serializeLayoutResizeSource,
} from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -864,6 +869,7 @@ export const boardRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: layoutsMap.get(layout.id)!,
boardId: newBoardId,
resizeSource: null,
}));
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
@@ -1358,6 +1364,7 @@ export const boardRouter = createTRPCRouter({
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
const resizeSourcesToUpdate = new Map<string, string | null>();
for (const updatedLayout of layoutsToUpdate) {
const dbLayout = existingLayoutsById.get(updatedLayout.id);
@@ -1386,6 +1393,7 @@ export const boardRouter = createTRPCRouter({
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
},
});
resizeSourcesToUpdate.set(updatedLayout.id, projectedLayout.resizeSource);
itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
}
@@ -1441,6 +1449,7 @@ export const boardRouter = createTRPCRouter({
await transaction
.update(schema.layouts)
.set({
...getResizeSourceUpdate(resizeSourcesToUpdate, layout.id),
name: layout.name,
columnCount: layout.columnCount,
leftGutterColumnCount: layout.leftGutterColumnCount,
@@ -1499,6 +1508,7 @@ export const boardRouter = createTRPCRouter({
transaction
.update(layouts)
.set({
...getResizeSourceUpdate(resizeSourcesToUpdate, layout.id),
name: layout.name,
columnCount: layout.columnCount,
leftGutterColumnCount: layout.leftGutterColumnCount,
@@ -1564,6 +1574,10 @@ export const boardRouter = createTRPCRouter({
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.layouts)
.set({ resizeSource: null })
.where(eq(schema.layouts.id, targetLayout.id));
await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
if (projectedLayout.itemSectionLayouts.length > 0) {
@@ -1576,6 +1590,7 @@ export const boardRouter = createTRPCRouter({
},
handleSync(db) {
db.transaction((transaction) => {
transaction.update(layouts).set({ resizeSource: null }).where(eq(layouts.id, targetLayout.id)).run();
transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
if (projectedLayout.itemSectionLayouts.length > 0) {
@@ -2511,8 +2526,14 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
}
};
const getResizeSourceUpdate = (sources: ReadonlyMap<string, string | null>, layoutId: string) => {
if (!sources.has(layoutId)) return {};
return { resizeSource: sources.get(layoutId) ?? null };
};
interface BoardForLayoutProjection {
id: string;
layouts: Array<{ id: string; resizeSource?: string | null }>;
items: Array<{
id: string;
layouts: Array<{
@@ -2547,7 +2568,13 @@ const getUpdatedBoardLayout = (
current: BoardLayoutGeometry;
},
) => {
const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const currentElements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const savedLayout = board.layouts.find((layout) => layout.id === options.previous.layoutId);
let serializedSource: string | null | undefined;
if (options.previous.layoutId === options.current.layoutId) serializedSource = savedLayout?.resizeSource;
const source = getLayoutResizeSource(options.previous, currentElements, serializedSource);
const elements = source.elements;
const previousGeometry = { ...source.geometry, layoutId: options.previous.layoutId };
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
@@ -2586,12 +2613,12 @@ const getUpdatedBoardLayout = (
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
getBoardLaneColumnCount(previousGeometry, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
? getBoardLaneColumnCount(previousGeometry, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return [
@@ -2644,6 +2671,7 @@ const getUpdatedBoardLayout = (
return {
itemSectionLayouts: itemSectionLayoutsCollection,
sectionLayouts: sectionLayoutsCollection,
resizeSource: serializeLayoutResizeSource(source, options.current, updatedElements),
};
};
@@ -2948,7 +2976,11 @@ const getFullBoardWithWhereAsync = async (
return {
...otherBoardProperties,
layouts: layouts
.map(({ boardId: _, ...layout }) => layout)
.map(({ boardId: _, resizeSource, ...layout }) => {
const result: typeof layout & { resizeSource?: string | null } = layout;
if (resizeSource) result.resizeSource = resizeSource;
return result;
})
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
@@ -352,6 +352,7 @@ export const onboardRouter = createTRPCRouter({
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
resizeSource: null,
role: "mobile" as const,
},
{
@@ -362,6 +363,7 @@ export const onboardRouter = createTRPCRouter({
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
resizeSource: null,
role: "base" as const,
},
]
+12 -5
View File
@@ -1,4 +1,4 @@
import { generateResponsiveGridFor } from "@homarr/common";
import { generateResponsiveGridFor, getLayoutResizeSource } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
@@ -10,6 +10,7 @@ export interface BoardPreviewLayout {
rightGutterColumnCount: number;
breakpoint: number;
role: LayoutRole;
resizeSource?: string | null;
}
interface BoardPreviewItemLayout {
@@ -77,7 +78,13 @@ export const projectBoardLayout = (
sourceLayout: LayoutGeometry,
targetLayout: LayoutGeometry,
) => {
const elements = getElementsForLayout(board, sourceLayout.id);
const currentElements = getElementsForLayout(board, sourceLayout.id);
let serializedSource: string | null | undefined;
if (sourceLayout.id === targetLayout.id) {
serializedSource = board.layouts.find((layout) => layout.id === sourceLayout.id)?.resizeSource;
}
const source = getLayoutResizeSource(sourceLayout, currentElements, serializedSource);
const elements = source.elements;
if (
sourceLayout.id === targetLayout.id &&
@@ -85,7 +92,7 @@ export const projectBoardLayout = (
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
) {
return elements;
return currentElements;
}
const emptyRoots = board.sections
@@ -124,12 +131,12 @@ export const projectBoardLayout = (
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
getBoardLaneColumnCount(source.geometry, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
? getBoardLaneColumnCount(source.geometry, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return generateResponsiveGridFor({
+2
View File
@@ -12,3 +12,5 @@ export * from "./error";
export * from "./theme";
export * from "./function";
export * from "./grid-algorithm";
export * from "./layout-resize-source";
@@ -0,0 +1,79 @@
import { z } from "zod/v4";
import type { GridAlgorithmItem } from "./grid-algorithm";
const geometrySchema = z.object({
columnCount: z.number().int().positive(),
leftGutterColumnCount: z.number().int().nonnegative(),
rightGutterColumnCount: z.number().int().nonnegative(),
});
const elementSchema = z.object({
id: z.string(),
type: z.enum(["item", "section"]),
sectionId: z.string(),
xOffset: z.number().int().nonnegative(),
yOffset: z.number().int().nonnegative(),
width: z.number().int().positive(),
height: z.number().int().positive(),
});
const resizeSourceSchema = z.object({
geometry: geometrySchema,
elements: z.array(elementSchema),
projectedGeometry: geometrySchema,
projectedElements: z.array(elementSchema),
});
type LayoutGeometry = z.infer<typeof geometrySchema>;
export interface LayoutResizeSource {
geometry: LayoutGeometry;
elements: GridAlgorithmItem[];
}
const geometryKey = (geometry: LayoutGeometry) =>
JSON.stringify([geometry.columnCount, geometry.leftGutterColumnCount, geometry.rightGutterColumnCount]);
const elementsKey = (elements: readonly GridAlgorithmItem[]) =>
JSON.stringify(
elements
.map((element) => [
element.id,
element.type,
element.sectionId,
element.xOffset,
element.yOffset,
element.width,
element.height,
])
.toSorted((first, second) => String(first[0]).localeCompare(String(second[0]))),
);
/** Keep the pre-resize arrangement only while the projected placements remain untouched. */
export const getLayoutResizeSource = (
geometry: LayoutGeometry,
elements: GridAlgorithmItem[],
serializedSource?: string | null,
): LayoutResizeSource => {
if (serializedSource) {
try {
const parsed = resizeSourceSchema.safeParse(JSON.parse(serializedSource));
if (
parsed.success &&
geometryKey(parsed.data.projectedGeometry) === geometryKey(geometry) &&
elementsKey(parsed.data.projectedElements) === elementsKey(elements)
) {
return { geometry: parsed.data.geometry, elements: parsed.data.elements };
}
} catch {
// Malformed or obsolete history must never prevent resizing the current layout.
}
}
return { geometry, elements };
};
export const serializeLayoutResizeSource = (
source: LayoutResizeSource,
projectedGeometry: LayoutGeometry,
projectedElements: GridAlgorithmItem[],
): string | null => {
if (geometryKey(source.geometry) === geometryKey(projectedGeometry)) return null;
return JSON.stringify({ ...source, projectedGeometry, projectedElements });
};
@@ -0,0 +1 @@
ALTER TABLE "layout" ADD COLUMN "resize_source" text;
File diff suppressed because it is too large. Load diff
@@ -113,6 +113,13 @@
"when": 1788763848932,
"tag": "0015_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1790988597957,
"tag": "0016_add_layout_resize_source",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `layout` ADD `resize_source` text;
File diff suppressed because it is too large. Load diff
@@ -337,6 +337,13 @@
"when": 1788763848930,
"tag": "0047_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1790988595595,
"tag": "0048_add_layout_resize_source",
"breakpoints": true
}
]
}
+1
View File
@@ -349,6 +349,7 @@ export const layouts = pgTable("layout", {
rightGutterColumnCount: smallint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
resizeSource: text(),
});
export const itemLayouts = pgTable(
+1
View File
@@ -334,6 +334,7 @@ export const layouts = sqliteTable("layout", {
rightGutterColumnCount: int().notNull().default(0),
breakpoint: int().notNull().default(0),
role: text().$type<LayoutRole>().notNull().default("custom"),
resizeSource: text(),
});
export const itemLayouts = sqliteTable(