Skip to content

fix(client): stop the resize buttons growing a widget over its neighbor - #177

Merged
jherforth merged 2 commits into
jherforth:mainfrom
mrramam:fix/resize-collision-guard
Sep 15, 2026
Merged

jherforth merged 2 commits into
jherforth:mainfrom
mrramam:fix/resize-collision-guard

Conversation

@mrramam

@mrramam mrramam commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The problem

The dashboard grid is configured to block overlaps compactType null plus preventCollision so react-grid-layout refuses a drag that would land a widget on top of another one.

The resize buttons never went through that path. handleResize in WidgetContainer mutates the layout item directly, checking only the grid bounds, then calls saveLayoutsToApi unconditionally. Nothing asked whether the larger widget still fit.

So a resize could put a widget on top of its neighbor, and the overlap was persisted. It survives reloads, because the stored layout is what the next load rebuilds from.

Why it looks like a different bug

An overlap consumes the free cells a drag needs. On a full tab there are none left, so a drag has nowhere legal to land: react-grid-layout refuses the move and returns the layout unchanged, handleLayoutChange sees nothing changed and returns before saving. The widget snaps back and no request is sent.

The dashboard reads as "layout changes don't save", while the actual cause is an overlap created earlier by a resize. It compounds every overlapping resize removes more free space, so more drags are refused, until a tab accepts no moves at all.

It is silent in three separate places: the refused drag reports nothing, the unchanged-layout check returns early, and saveLayoutsToApi ends in an empty catch. Nothing reaches the user or the console.

Only dashboards packed tightly enough to have no free cells are affected, which is why it can sit unnoticed for a long time on a board with slack.

The fix

canCommitResize applies react-grid-layout's own collision rule to the resize path. A resize that would overlap is abandoned and the layout is returned untouched, so nothing is saved.

Shrinking is always allowed. It can only free cells, and refusing it would trap a widget on a dashboard that is already overlapping which is the state this bug leaves behind, so that escape hatch has to keep working.

The helper is plain functions in client/src/utils/, with no React and no grid library, so the decision is unit-testable without a renderer.

Testing

9 new unit tests in client/src/utils/resizeGuard.test.js cover growth into free space, growth blocked by a neighbor, growth left and up, shrinking while already overlapping, and the production layout that prompted this. Full client suite passes.

Verified on a live install: before the change, a resize toward a neighbor overlaps it and persists; after, the resize is refused and no write is sent, while shrinking and drags into free space still work.

The dashboard grid blocks overlaps — compactType null plus preventCollision —
so react-grid-layout refuses a drag that would land a widget on top of another
one. The resize buttons never went through that path. handleResize mutates the
layout item directly, checking only the grid bounds (item.x + item.w <
gridCols), and then calls saveLayoutsToApi unconditionally. Nothing asked
whether the larger widget still fit.

So a resize could put a widget on top of its neighbor, and the overlap was
persisted. It survives reloads, because the stored layout is what the next load
rebuilds from.

The second half is what makes it look like a different bug entirely. An overlap
consumes the free cells a drag needs, and on a full tab there are none left. A
drag then has nowhere legal to land, react-grid-layout refuses the move and
returns the layout unchanged, handleLayoutChange sees nothing changed and
returns before saving. The widget snaps back and no request is sent. The
dashboard reads as "layout changes don't save", while the actual cause is an
overlap created earlier by a resize.

It compounds: every resize that overlaps removes more free space, so more drags
are refused. A tab degrades toward accepting no moves at all.

Three separate paths make it silent. The refused drag reports nothing, the
unchanged-layout check returns early, and saveLayoutsToApi ends in an empty
catch. Nothing reaches the user or the console.

canCommitResize applies react-grid-layout's own collision rule to the resize
path. A resize that would overlap is abandoned and the layout is returned
untouched, so nothing is saved. Shrinking is always allowed — it can only free
cells, and refusing it would trap a widget on a dashboard that is already
overlapping, which is the state this bug leaves behind.

The helper is plain functions in client/src/utils/, with no React and no grid
library, so the decision is unit-testable without a renderer.
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>
@jherforth

Copy link
Copy Markdown
Owner

Reviewed critically and merging. I resolved the conflict, reproduced the bug on main, and confirmed the fix and the two escape hatches.

Conflict

Trivial — #176 landed in WidgetContainer first and both sides added an import at the same line. Both kept. Nothing else collided: your guard sits inside handleResize, while #176 touched the rebuild effect, handleLayoutChange and the grid's layout prop. Pushed as 1ec0b99.

Reproduced on main, fixed here

Two adjacent widgets, chores at (0,0,6x4) and calendar at (6,0,6x4). One click of grow-right:

[MAIN]  saves: 1
  before: calendar=(6,0,6x4)  chores=(0,0,6x4)   overlap=null
  after : calendar=(6,0,6x4)  chores=(0,0,7x4)   overlap=calendar/chores
[PR177] saves: 0
  before: calendar=(6,0,6x4)  chores=(0,0,6x4)   overlap=null
  after : calendar=(6,0,6x4)  chores=(0,0,6x4)   overlap=null

Main grows the widget over its neighbour and writes it to the database. The PR refuses and sends nothing.

Both escape hatches still work

These were the regression risks, so I drove them rather than reasoning about them:

[grow-into-gap]      chores 4x4 -> 5x4,  saves: 1     growth into free space still commits
[shrink-overlapping] chores 7x4 -> 6x4,  saves: 1     shrink out of an overlap still commits

The second matters most, and you were right to call it out: it is the state the old bug leaves dashboards in, so refusing it would strand people.

On the grew predicate

!grew means w <= prev.w && h <= prev.h && x >= prev.x && y >= prev.y, which is not by itself sufficient — x increasing while w stays the same slides the right edge onto a neighbour and would be waved through. I checked whether handleResize can produce that, and it cannot: every x increase is paired with w - 1 (left-shrink) and every y increase with h - 1 (top-shrink), so the far edge never moves outward. Same for the growth cases, which are all caught by w > or h >.

So the predicate is sound for this caller. Worth knowing that it is looser than it looks if canCommitResize ever picks up a second caller that can translate an item rather than only resize it — the exported, separately-tested helper reads more general than it is.

Also worth knowing (pre-existing, not yours)

handleResize calls onLayoutChangeCallback and saveLayoutsToApi inside the setLayout updater. That is an impure updater, so React can invoke it twice in StrictMode and fire the save twice. Your guard sits correctly before both, so returning currentLayout suppresses them — the placement is right. The debounce in saveLayoutsToApi collapses a double call anyway. Mentioning it only because it is the kind of thing that bites later.

Verification

Client 302/302 (+9), server 225/225, translation parity, build clean, CI green on both jobs after the merge.

Good write-up, incidentally. "The dashboard reads as layout changes don't save, while the actual cause is an overlap created earlier by a resize" is the sort of connection that saves someone a day of chasing the wrong endpoint — and the three-places-silent observation explains why it went unreported for so long.

@jherforth
jherforth merged commit 76530c0 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 jherforth added the bug Something isn't working label Sep 15, 2026
@jherforth jherforth added this to the 1.9 milestone Sep 15, 2026
@mrramam
mrramam deleted the fix/resize-collision-guard 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

bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants