Skip to content

fix(Board): place a cross-board swap arrival instead of cancelling it - #1370

Merged
tenphi merged 6 commits into
mainfrom
andrew/cub-4156-board-collisionmodeswap-should-place-a-cross-board-arrival
Aug 27, 2026
Merged

fix(Board): place a cross-board swap arrival instead of cancelling it#1370
tenphi merged 6 commits into
mainfrom
andrew/cub-4156-board-collisionmodeswap-should-place-a-cross-board-arrival

Conversation

@tenphi

@tenphi tenphi commented Aug 27, 2026

Copy link
Copy Markdown
Member

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 and onWidgetTransfer never 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 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 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, moveElement restores 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 at y: -1, above the grid. Neither the overlap guard nor isOverlapFree catches 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 existing placeInFreeSlot), which fixes this for every collision mode, not just swapdownscale has had it all along.

Implementation notes for the reviewer

  • strictIncomingSwap and the targetLandingRef that existed only to drive its cancel branches are gone (net −40 lines in the registry). The big diff in use-board-registry.ts is mostly the un-indenting of the former else block — ?w=1 is much easier to read.
  • Both cross-board paths already passed allowExchange: false to the resolver, so the engine (collision-modes.ts) needed no behavioural change — only a doc comment that no longer promises a cancel.
  • The three "cancels…" tests are rewritten as placement tests with exact pinned cells rather than loose assertions, so the landing rule is documented rather than merely "somewhere valid":
    • anchor occupied by a same-size widget → (2, 0), the next free cell in reading order;
    • minW: 4 with only 3 free columns → (0, 1) at full size, since downscaling is ruled out;
    • a release over an occupied cell mid-drag → commits the last valid preview instead of blinking the placeholder away.
  • Two more regression tests: one pins the y: -1 fix under downscale, one pins the sweep-hold behaviour below.

Review round 2 — d16e5ead

Bugbot caught a real bug in the first push, and it's worth recording since the distinction is subtle. 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, 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 regressed downscale too, 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 a minor. Anything relying on an occupied anchor cancelling a cross-board transfer will now get a placement instead. Nothing in-tree relied on it.

Checklist
  • Pipeline is passed
  • Tests are added (including unit tests and stories in the storybook)
  • Tests are passed successfully
  • If you're adding a new component/new props, add stories that describe how this component/prop works
  • Changeset(s) is(are) added
  • You have passed the threshold of the library size
  • Commit message follows commit guidelines

Closes: CUB-4156

Other information

Full suite green: 2196 passed, 1 skipped (102 files), plus 38 Board browser tests. pnpm lint clean; pnpm audit-docs unchanged for Board.

Docs, the collisionMode prop 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 like downscale: 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 use landIncoming / placeInFreeSlot for the nearest fitting cell. strictIncomingSwap and targetLandingRef are removed so commits follow the carried preview instead of requiring an empty anchor.

downscale (and the cross-board half of swap) gains clampedOutAnchors in collision-modes.ts: when the drag anchor is pinned at cols - w or maxRows - 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: -1 landing 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.

…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>
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 27, 2026 10:43am

Request Review

@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e071e92

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

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

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-7a8897b.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 500.4 KB (+0.03% 🔺) Yes 🎉
Tree shaking (just a Button) 118.96 KB (0% 🟰) Yes 🎉

Compared against main at 69d2e48run 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.

Comment thread src/components/layout/Board/use-board-registry.ts
…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>
`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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/components/layout/Board/grid-core/collision-modes.ts
`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>
@tenphi
tenphi merged commit 154a701 into main Aug 27, 2026
16 checks passed
@tenphi
tenphi deleted the andrew/cub-4156-board-collisionmodeswap-should-place-a-cross-board-arrival branch August 27, 2026 10:59
@tenphi tenphi mentioned this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant