fix(Board): place a cross-board swap arrival instead of cancelling it - #1370
Conversation
…it (CUB-4156) `collisionMode="swap"` treated a widget arriving from another board as strict insertion: the anchor cell had to be empty, and releasing over an occupied one cancelled the whole transfer - both boards snapped back and `onWidgetTransfer` never fired. That made the mode's two halves unusable together. A board that wanted in-board swapping had to either accept drops silently doing nothing, or pick `downscale` and give up the swap; the dashboard Grid container chose the latter, so the swap its authors asked for was never delivered. A cross-board arrival now resolves the way `downscale` does: it keeps its size where the drop cell allows, downscales into the room to its right and below where it does not, and lands in the nearest cell that fits when the anchor is occupied outright. Destination widgets are still never exchanged, pushed, or reflowed - only the arrival moves. In-board drops are untouched and still swap. Removing the strict path exposed a latent bug it had been masking. When a placement is refused, `moveElement` restores the item to the synthetic seed cell one row above (or column left of) the anchor - which sits off the board entirely when the anchor is in row 0. Dropping onto a full row 0 therefore committed the widget at `y: -1`. `landIncoming` now sends a refused arrival to the nearest cell that can hold it, fixing this for every collision mode, not just `swap`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: e071e92 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📦 NPM canary releaseDeployed canary version 0.0.0-canary-7a8897b. |
🧪 Storybook is successfully deployed!
|
🏋️ Size limit report
Compared against main at 69d2e48 — run 33060413774, 2026-08-27T09:50:55Z.To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html. |
…ld-place-a-cross-board-arrival
…a widget `landIncoming` relocated on every refused frame, but `moveElement` only falls back to the synthetic off-board seed on the *first* frame over a board. Every later frame falls back to the cell the previous frame settled on - a real, on-board cell - so relocating there made the placeholder jump to the nearest free slot the moment the pointer swept over a destination widget, and the drop committed that jump instead of what the preview had been showing. It also regressed `downscale`, which used to hold. Only the seeded first frame relocates now; later frames keep the cell they found. Entering a board directly over an occupied cell still places the widget in the nearest cell that fits, which is what the off-board landing fix needed. Reported by Cursor Bugbot on #1370. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ld-place-a-cross-board-arrival
`gridBounds` clamps a drag anchor to `cols - w`, which assumes the widget keeps the size it started with. When the blocker sat to the LEFT and the room to its right was narrower than the widget, every anchor a pointer could produce landed inside the blocker, so `downscale` was never offered the room it exists to shrink into and the drop reverted. The mirror case always worked, because column 0 is reachable whatever the widget's width - downscaling worked on one side of a blocker and not the other. A blocked drop now also considers the cells the widget already covers that the clamp put out of reach, taking the largest fit among them. Anchors a pointer could have selected on its own are deliberately excluded, so every drop that resolved before resolves to exactly the same cell. The row axis clamps the same way, so a finite `maxRows` gets the same fix below a blocker. `landIncoming` now reads a refusal off the seed origin rather than off the requested cell, since a resolver is allowed to place the widget somewhere other than the exact cell asked for - which this change makes it do. The cross-board story moves its blocker to the left so it demonstrates the direction that was broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5372cbe. Configure here.
`clampedOutAnchors` filtered out the cells a pointer could reach, but never checked whether the anchor itself was actually pinned at the limit. A blocked drop at a freely chosen cell therefore recovered the far cells anyway: a 4-wide widget dropped on a 1-wide blocker at column 0 of a 6-column grid landed at column 3, skipping columns 1 and 2 - both free, both reachable - and taking a rectangle outside the footprint it was hovering. For a cross-board arrival this also outranked `landIncoming`, which would have placed it in the nearest free slot. Recovery is now gated on the anchor sitting on the limit, per axis, and slides only along the axis that pinned it. A pinned span ends at the last row/column, so every recovered rectangle now provably lies inside the hovered footprint - which is what the comment claimed all along. Reported by Cursor Bugbot on #1370. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Describe changes
<Board collisionMode="swap">treated a widget arriving from another board as strict insertion: the anchor cell had to be empty, and releasing over an occupied one cancelled the whole transfer — both boards snapped back andonWidgetTransfernever fired.That made the mode's two halves unusable together. A board that wanted in-board child↔child swapping had to either accept that cross-board drops silently do nothing, or pick
collisionMode="downscale"and give up the swap. The dashboard Grid container (CUB-3811, cubedevinc/cubejs-enterprise#13919) chose the latter — a drop that does nothing is a worse regression than no swap — so the swap its authors asked for was never delivered.A cross-board arrival now resolves the way
downscaledoes: it keeps its size where the drop cell allows, downscales into the room to its right and below where it does not, and holds the last cell it fitted in as the pointer sweeps across a destination widget — so releasing over an occupied cell commits what the preview was showing. Entering a board directly over an occupied cell places the widget in the nearest cell that fits. Destination widgets are still never exchanged, pushed, or reflowed — only the arrival moves. In-board drops are untouched and still swap. That one change lets the Grid container switch back to"swap"and get both behaviours, with no other Cloud change needed.Bonus fix, and the reason this isn't a one-liner
Removing the strict path exposed a latent bug it had been masking. When a placement is refused,
moveElementrestores the item to the synthetic seed cell one row above (or column left of) the anchor — the cell the registry seeds so the first frame is an active placement rather than a no-op. That cell sits off the board entirely when the anchor is in row 0, so dropping onto a full row 0 committed the widget aty: -1, above the grid. Neither the overlap guard norisOverlapFreecatches it, since an off-grid cell overlaps nothing.The new
landIncoming()helper sends a refused arrival to the nearest cell that can actually hold it (reusing the existingplaceInFreeSlot), which fixes this for every collision mode, not justswap—downscalehas had it all along.Implementation notes for the reviewer
strictIncomingSwapand thetargetLandingRefthat existed only to drive its cancel branches are gone (net −40 lines in the registry). The big diff inuse-board-registry.tsis mostly the un-indenting of the formerelseblock —?w=1is much easier to read.allowExchange: falseto the resolver, so the engine (collision-modes.ts) needed no behavioural change — only a doc comment that no longer promises a cancel.(2, 0), the next free cell in reading order;minW: 4with only 3 free columns →(0, 1)at full size, since downscaling is ruled out;y: -1fix underdownscale, one pins the sweep-hold behaviour below.Review round 2 —
d16e5eadBugbot caught a real bug in the first push, and it's worth recording since the distinction is subtle.
landIncomingrelocated on every refused frame — butmoveElementonly falls back to the synthetic off-board seed on the first frame over a board. Every later frame falls back to the cell the previous frame settled on, which is a perfectly good on-board cell. Relocating there made the placeholder jump to the nearest free slot the moment the pointer swept over a destination widget, and the drop committed that jump rather than what the preview had been showing. It regresseddownscaletoo, which used to hold.Only the seeded first frame relocates now. Note the original sweep test passed by coincidence — its held cell and its relocated cell happened to be the same one; the added case is one where they differ (hold at
(4,0)vs. jump to(0,0)), confirmed failing before the fix.Behaviour change
This changes documented behaviour of
collisionMode="swap"shipped in 0.168, hence aminor. Anything relying on an occupied anchor cancelling a cross-board transfer will now get a placement instead. Nothing in-tree relied on it.Checklist
Closes: CUB-4156
Other information
Full suite green: 2196 passed, 1 skipped (102 files), plus 38 Board browser tests.
pnpm lintclean;pnpm audit-docsunchanged for Board.Docs, the
collisionModeprop JSDoc, and the "swap across boards" story caption are all updated — each of them previously told the reader the transfer would cancel.🤖 Generated with Claude Code
Note
Medium Risk
Changes documented Board drag/transfer and collision-resolution behavior across cross-board drops and downscale edge cases; regressions would show up as wrong widget positions or cancelled transfers, but scope stays within layout grid logic with heavy test coverage.
Overview
Cross-board
collisionMode="swap"no longer cancels the transfer when the anchor is occupied or the preview had to downscale. Arrivals behave likedownscale: keep size when possible, shrink into adjacent room, hold the last valid preview while sweeping over blockers, and on first entry over an occupied cell uselandIncoming/placeInFreeSlotfor the nearest fitting cell.strictIncomingSwapandtargetLandingRefare removed so commits follow the carried preview instead of requiring an empty anchor.downscale(and the cross-board half of swap) gainsclampedOutAnchorsincollision-modes.ts: when the drag anchor is pinned atcols - wormaxRows - h, resolution can try the cells the clamp hid and pick the largest fit—fixing drops that used to revert when free room was only to the right (or below) a blocker. Anchors not pinned on the limit still revert as before.Docs, stories, and tests are updated for the new swap semantics, sweep-hold behavior,
y: -1landing fix, and clamp recovery cases.Reviewed by Cursor Bugbot for commit e071e92. Bugbot is set up for automated code reviews on this repo. Configure here.