From 85bb0c8c0b7fdd1c8b610d5f3ef0af469e3b0946 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Sun, 26 Jul 2026 20:00:16 +0200 Subject: [PATCH] fix: restore saved gridstack layout faithfully (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout was saved to localStorage, but restoring it did not reliably reproduce it: - restore() applied the saved nodes one by one via grid.update(). A later item could collide with one already put back and push it off its saved spot, and nothing moved it back — so the restored layout was not the one the user left. Use grid.load(nodes, false) instead: it sorts the nodes, removes them from the engine before re-placing them, and runs in a single batch. addRemove is off because Flow owns which children exist. - gridstack's save() omits w/h when they are 1. The manual restore passed them through as undefined, and the server read them as 0 and wrote a zero-sized widget back onto the item. load() re-applies gridstack's own defaults; onLayoutChange now reads a missing w/h as 1. - A drag followed immediately by navigating to another route detached the element inside the 150ms persist debounce, dropping the pending save. disconnectedCallback now flushes it to localStorage first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq --- src/main/frontend/components/grid-stack.ts | 46 +++++++++++++++---- .../example/components/GridStackLayout.java | 21 ++++----- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/main/frontend/components/grid-stack.ts b/src/main/frontend/components/grid-stack.ts index 503803a..554d29f 100644 --- a/src/main/frontend/components/grid-stack.ts +++ b/src/main/frontend/components/grid-stack.ts @@ -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; - 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 { diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java index 152ce62..d66deee 100644 --- a/src/main/java/com/example/components/GridStackLayout.java +++ b/src/main/java/com/example/components/GridStackLayout.java @@ -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));