Skip to content

fix(client): stop tab changes corrupting and scrambling widget layouts - #176

Merged
jherforth merged 1 commit into
jherforth:mainfrom
mrramam:fix/tab-change-layout-clean
Sep 15, 2026
Merged

jherforth merged 1 commit into
jherforth:mainfrom
mrramam:fix/tab-change-layout-clean

Conversation

@mrramam

@mrramam mrramam commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #173

Switching tabs with the dashboard unlocked overwrote the destination tab's saved layout, and visibly shuffled the widgets while doing it. Both came from the same place: the grid being handed a layout that did not describe the children it was rendering.

layout state is rebuilt by an effect, so during a tab change it still describes the previous tab while the children are already the new tab's. Passing it straight to the grid gives entries whose i matches no child, and children with no entry at all. With preventCollision set, the grid synthesises placements for the orphans and moves non-static items around until nothing collides - the visible scramble. It then reports that arrangement through onLayoutChange, and it was persisted under the new tab's number.

Widgets present on both tabs inherited the previous tab's coordinates; widgets only on the destination tab fell through to item.minW ?? 2 and collapsed to 2x2. On a two-tab dashboard of six widgets and two, one unlocked switch destroyed the six-widget tab and left three overlapping pairs. It only happened unlocked because static items are pinned and excluded from collision movement, which is why a wall display never showed it.

Two changes:

buildLayout derives the layout during render, one entry per child, from widget.savedLayout which the caller has already scoped to the active tab, so it is correct even mid-transition. A widget without a saved layout takes the first free cell rather than its default position: defaults are not unique, every plugin widget declares (0,0) at 6x4, and honouring them naively stacks the whole plugin set in one place. The rebuild effect now uses the same helper instead of its own copy of the placement loop.

shouldAcceptLayoutChange refuses to persist a layout whose tab does not match the active one. The derived layout should prevent a bad arrangement from being reported at all, but writing one tab's geometry under another's number is the damaging outcome here, so it is worth refusing outright. The lock transition takes the same check, since it persists on its own trigger.

Tab 0 is falsy and valid, so the "no layout yet" case is an explicit null check rather than a truthiness test.

Verified on a live deployment: the corruption was deterministic beforehand - byte-identical damage from two different routes to the same tab - and after the change a tab switch writes nothing, a drag still persists, and no overlaps are created.

Switching tabs with the dashboard unlocked overwrote the destination tab's saved
layout, and visibly shuffled the widgets while doing it. Both came from the same
place: the grid being handed a layout that did not describe the children it was
rendering.

`layout` state is rebuilt by an effect, so during a tab change it still describes
the previous tab while the children are already the new tab's. Passing it
straight to the grid gives entries whose `i` matches no child, and children with
no entry at all. With preventCollision set, the grid synthesises placements for
the orphans and moves non-static items around until nothing collides — the
visible scramble. It then reports that arrangement through onLayoutChange, and it
was persisted under the new tab's number.

Widgets present on both tabs inherited the previous tab's coordinates; widgets
only on the destination tab fell through to `item.minW ?? 2` and collapsed to
2x2. On a two-tab dashboard of six widgets and two, one unlocked switch destroyed
the six-widget tab and left three overlapping pairs. It only happened unlocked
because static items are pinned and excluded from collision movement, which is
why a wall display never showed it.

Two changes:

`buildLayout` derives the layout during render, one entry per child, from
`widget.savedLayout` — which the caller has already scoped to the active tab, so
it is correct even mid-transition. A widget without a saved layout takes the
first free cell rather than its default position: defaults are not unique, every
plugin widget declares (0,0) at 6x4, and honouring them naively stacks the whole
plugin set in one place. The rebuild effect now uses the same helper instead of
its own copy of the placement loop.

`shouldAcceptLayoutChange` refuses to persist a layout whose tab does not match
the active one. The derived layout should prevent a bad arrangement from being
reported at all, but writing one tab's geometry under another's number is the
damaging outcome here, so it is worth refusing outright. The lock transition
takes the same check, since it persists on its own trigger.

Tab 0 is falsy and valid, so the "no layout yet" case is an explicit null check
rather than a truthiness test.

Verified on a live deployment: the corruption was deterministic beforehand -
byte-identical damage from two different routes to the same tab - and after the
change a tab switch writes nothing, a drag still persists, and no overlaps are
created.
@jherforth

Copy link
Copy Markdown
Owner

Reviewed critically and merging. I reproduced the scramble on main and confirmed the fix against it, and I have two findings plus one thing I could not verify — all below.

The scramble reproduces, and the fix is exact

Seeded a two-tab device — tab 1 {chores, calendar}, tab 2 {chores, weather} — with deliberately distinct saved layouts, unlocked, then switched tabs and read the rendered transforms:

tab 2 saved as:  chores=(2,5)  weather=(7,5)

[MAIN]  translate(0px, 0px)  and  translate(0px, 0px)     ← both stacked
[PR176] translate(231px, 580px) and translate(807px, 580px)

At 1400px with 12 columns those work out to x=2 and x=7, y=5 — the saved layout, exactly. Main collapses both widgets onto the origin. That is the visible bug, and deriving the layout from widget.savedLayout during render fixes it precisely.

I also confirmed a tab switch now writes nothing: 0 calls to widget-assignments/layout/bulk across the switch, layouts byte-identical before and after.

The guard cannot get stuck, which was my main worry

shouldAcceptLayoutChange refuses until layoutTab === activeTab, so I went looking for a state where the ref never catches up — that would silently stop all layout saves. It cannot happen: the rebuild effect's cache key is `${activeTab}:${ids}`, so every tab change invalidates it and sets layoutTabRef.current = activeTab, even when both tabs hold the same widget set. And activeTab is a number throughout (useState(1), setActiveTab(tabNumber)), so the strict === — and your test pinning it against '1' — is safe rather than a trap.

Finding: buildLayout can still overlap, and the comment says it cannot

The header says "One entry per widget, never overlapping", but collision is only checked against widgets placed so far. A saved widget appearing after an unsaved one keeps its coordinates on top of it:

buildLayout([unsaved, saved@(0,0,6x4)], 12, false)
  -> [{i:'fresh', x:0,y:0,w:6,h:4}, {i:'saved', x:0,y:0,w:6,h:4}]   overlap

Reversing the order — which is what mixes saved and unsaved widgets without collisions does — passes. So the property holds in the tested order only.

This is pre-existing: main's inline loop is the same single pass, and buildLayout is a faithful extraction. Not a blocker, and not something this PR made worse. But it is the "enable a new widget on a tab that already has saved layouts" case, which is common — and with your change the resulting shuffle now passes the tab guard, since it is the same tab, so it can still be persisted. A two-pass placement (all saved first, then fill) would close it and make the comment true.

Nit: the explanation is in there twice

The gridLayout useMemo carries two near-identical comment paragraphs, both opening "The layout handed to the grid must describe exactly the children being rendered". Looks like a paste artifact — the second is the tighter one.

What I could not verify

That a drag still persists. I could not drive react-grid-layout's drag synthetically — mouse-down/move/up produced no layout save, but it produced none on main either, so that is my harness failing to grab the item, not a signal about the guard. Your unit tests cover the accepting case and the structural argument above says the guard cannot be permanently closed, so I am satisfied, but I did not observe a real drag round-trip and am not claiming otherwise.

Client 293/293 (+12), translation parity, build clean, CI green on both jobs.

Good diagnosis, incidentally — "the grid being handed a layout that did not describe the children it was rendering" is the sentence that makes both symptoms one bug.

@jherforth
jherforth merged commit e98c4c8 into jherforth:main Sep 15, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Sep 15, 2026
jherforth added a commit to mrramam/HomeGlow that referenced this pull request Sep 15, 2026
jherforth#176 landed in WidgetContainer first, so both sides added imports at the same
place. Nothing else collided: the resize guard sits inside handleResize, and
jherforth#176 touched the rebuild effect, handleLayoutChange and the grid's layout prop.
Both import lines are kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mrramam
mrramam deleted the fix/tab-change-layout-clean branch September 15, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Switching tabs while unlocked scrambles the widgets and overwrites the destination tab's saved layout

2 participants