fix: restore saved gridstack layout faithfully (#13) #15

Merged
pitfriedrich merged 1 commits from ai/issue-13-persist-layout into main 2026-07-26 18:07:49 +00:00
2 changed files with 46 additions and 21 deletions
Showing only changes of commit 85bb0c8c0b - Show all commits
+36 -10
View File
@@ -20,7 +20,12 @@ class GridStackLayout extends HTMLElement {
disconnectedCallback() {
this.observer?.disconnect();
this.observer = undefined;
clearTimeout(this.persistTimer);
// Write out a still-pending debounced save instead of dropping it:
// a drag/resize followed straight away by navigating to another route
// detaches this element inside the debounce window, which used to lose
// the very last layout change. The server is not notified — its view is
// going away with us.
this.flushPersist();
this.grid?.destroy(false); // keep DOM — Flow owns the children
this.grid = undefined;
}
@@ -99,16 +104,28 @@ class GridStackLayout extends HTMLElement {
private schedulePersist() {
clearTimeout(this.persistTimer);
this.persistTimer = setTimeout(() => this.persist(), 150);
this.persistTimer = setTimeout(() => {
this.persistTimer = undefined;
this.persist();
}, 150);
}
private persist() {
private flushPersist() {
if (this.persistTimer === undefined) return;
clearTimeout(this.persistTimer);
this.persistTimer = undefined;
this.persist(false);
}
private persist(notifyServer = true) {
if (!this.grid) return;
const nodes = this.grid.save(false) as GridStackNode[];
if (this.storageKey) {
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
}
(this as any).$server?.onLayoutChange(JSON.stringify(nodes));
if (notifyServer) {
(this as any).$server?.onLayoutChange(JSON.stringify(nodes));
}
}
private restore() {
@@ -122,13 +139,22 @@ class GridStackLayout extends HTMLElement {
} catch {
return;
}
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node.id) continue;
const el = this.querySelector(`:scope > [gs-id="${node.id}"]`) as HTMLElement | null;
Review

Excellent fix 👍

Excellent fix 👍
if (!el) continue; // item no longer present server-side — skip, degrade gracefully
this.grid.update(el, { x: node.x, y: node.y, w: node.w, h: node.h });
}
// Items dropped server-side since the layout was saved are skipped, so a
// stale entry degrades gracefully instead of blocking the restore.
const known = nodes.filter((node) => node.id
&& this.querySelector(`:scope > [gs-id="${CSS.escape(String(node.id))}"]`));
if (!known.length) return;
// load() rather than a per-item update() loop: it sorts the saved nodes,
// pulls them out of the engine before re-placing them, and does the whole
// thing in one batch. Updating item by item let a later item collide with
// one already put back and push it off its saved spot for good — the
// restored layout was then not the one the user left. load() also re-applies
// gridstack's w/h defaults, which save() omits when they are 1.
// `false` keeps add/remove off: Flow owns which children exist.
this.grid.load(known, false);
}
private intAttr(el: HTMLElement, name: string): number | undefined {
@@ -179,18 +179,17 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
String id = node.path("id").asString(null);
if (id == null) continue;
findItem(id).ifPresent(item -> item.setPosition(
node.path("x").asInt(),
node.path("y").asInt(),
node.path("w").asInt(),
node.path("h").asInt()));
// gridstack omits w/h from its saved layout when they are 1, so a
// missing value means 1 — defaulting to 0 wrote a zero-sized widget
// back onto the item and lost its size on the next attach.
int x = node.path("x").asInt(0);
int y = node.path("y").asInt(0);
int w = node.path("w").asInt(1);
int h = node.path("h").asInt(1);
positions.add(new GridStackItem.Position(
id,
node.path("x").asInt(),
node.path("y").asInt(),
node.path("w").asInt(),
node.path("h").asInt()));
findItem(id).ifPresent(item -> item.setPosition(x, y, w, h));
positions.add(new GridStackItem.Position(id, x, y, w, h));
}
fireEvent(new LayoutChangeEvent(this, true, positions));