Compare commits

..

2 Commits

Author SHA1 Message Date
pitfriedrich f242bbd8a6 Merge pull request 'fix: restore saved gridstack layout faithfully (#13)' (#15) from ai/issue-13-persist-layout into main
Reviewed-on: #15
Reviewed-by: pitfriedrich <bp.webservice@gmail.com>
2026-07-26 18:07:48 +00:00
Pit Friedrich 85bb0c8c0b fix: restore saved gridstack layout faithfully (#13)
CI / build-and-test (pull_request) Successful in 1m17s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq
2026-07-26 20:00:16 +02:00
2 changed files with 46 additions and 21 deletions
+36 -10
View File
@@ -20,7 +20,12 @@ class GridStackLayout extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this.observer?.disconnect(); this.observer?.disconnect();
this.observer = undefined; 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?.destroy(false); // keep DOM — Flow owns the children
this.grid = undefined; this.grid = undefined;
} }
@@ -99,16 +104,28 @@ class GridStackLayout extends HTMLElement {
private schedulePersist() { private schedulePersist() {
clearTimeout(this.persistTimer); 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; if (!this.grid) return;
const nodes = this.grid.save(false) as GridStackNode[]; const nodes = this.grid.save(false) as GridStackNode[];
if (this.storageKey) { if (this.storageKey) {
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes)); 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() { private restore() {
@@ -122,13 +139,22 @@ class GridStackLayout extends HTMLElement {
} catch { } catch {
return; return;
} }
if (!Array.isArray(nodes)) return;
for (const node of nodes) { // Items dropped server-side since the layout was saved are skipped, so a
if (!node.id) continue; // stale entry degrades gracefully instead of blocking the restore.
const el = this.querySelector(`:scope > [gs-id="${node.id}"]`) as HTMLElement | null; const known = nodes.filter((node) => node.id
if (!el) continue; // item no longer present server-side — skip, degrade gracefully && this.querySelector(`:scope > [gs-id="${CSS.escape(String(node.id))}"]`));
this.grid.update(el, { x: node.x, y: node.y, w: node.w, h: node.h }); 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 { 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); String id = node.path("id").asString(null);
if (id == null) continue; if (id == null) continue;
findItem(id).ifPresent(item -> item.setPosition( // gridstack omits w/h from its saved layout when they are 1, so a
node.path("x").asInt(), // missing value means 1 — defaulting to 0 wrote a zero-sized widget
node.path("y").asInt(), // back onto the item and lost its size on the next attach.
node.path("w").asInt(), int x = node.path("x").asInt(0);
node.path("h").asInt())); 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( findItem(id).ifPresent(item -> item.setPosition(x, y, w, h));
id,
node.path("x").asInt(), positions.add(new GridStackItem.Position(id, x, y, w, h));
node.path("y").asInt(),
node.path("w").asInt(),
node.path("h").asInt()));
} }
fireEvent(new LayoutChangeEvent(this, true, positions)); fireEvent(new LayoutChangeEvent(this, true, positions));