Skip to content

feat(plugin-page-builder): resist target switching near a zone boundary [DRAFT — design refuted, reworking] - #810

Closed
mobeenabdullah wants to merge 2 commits into
mainfrom
feat/hook-drag-hysteresis
Closed

mobeenabdullah wants to merge 2 commits into
mainfrom
feat/hook-drag-hysteresis

Conversation

@mobeenabdullah

@mobeenabdullah mobeenabdullah commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Caution

This PR's central claim was refuted by measurement. It is in draft and the approach is being reworked. Do not cite the numbers below without reading this box.

WITHDRAWN: "measured 10.01px". That figure is true only of the fixture that produced it. shapeIntersection reports intersectionRatio / distance, and the two ratios are not equal — the incumbent's shrinks as the source leaves while the challenger's grows as it enters. Scaling the value by d / (d - band) therefore reduces to a distance band only when the ratios coincide, and the unit fixture made them coincide by hardcoding 1 / distance for both sides.

That is the pointer-containment shape.

Correction to an earlier version of this box: I wrote that gap zones "are zero-height, contain no point and never win containment". True at rest, wrong during a drag — .nx-pb-dropzone[data-drag]{height:6px;margin:3px 0} on main, so a zone is 6px tall mid-drag and does contain the pointer. The collision type therefore depends on pointer position: inside a zone's band it is pointerIntersection (1/d, no ratio); in the 6px gap between two zones it is shapeIntersection (ratio/d, ratios unequal).

The refutation stands, because a boundary is exactly where no zone contains the pointer — so hysteresis operates in the unequal-ratio path and the measurements below hold. But the mechanism is a mix of both types, which is worse than the original claim: sortCollisions ranks TYPE above value, so a pointer crossing from the gap into a zone changes type and the band is never consulted at all. Any band expressed in value is silently bypassed on that transition.

Measured across realistic geometries at band = 20:

source h zone h centre sep achieved
200 8 40 10.01px
60 8 40 8.22px
20 8 40 6.01px
60 40 60 4.48px

Requirement is 8-12px of pointer movement. The floor is breached, so the requirement is not met.

Two further defects, both real:

  • Ancestor/descendant stickiness. When append:<grid> is incumbent and the pointer enters a child, the shapes overlap and both report high-priority pointer intersections. If their centres are closer than the band, the challenger cannot close the gap by the triangle inequality — the append target is permanently sticky and the drop lands inside the grid instead of before the child. A functional regression, not a degraded margin.
  • Stale expected-failure markers. acceptance.spec.ts:552-561 and scenarios.spec.ts:437-448 carry test.fail(true, ...) on the hysteresis assertions. test.fail inverts the result, so any correct implementation turns both root E2E specs red with "Expected to fail, but passed". Their removal must land in the SAME change as the implementation — there is no ordering of two PRs that avoids a red main.

Five mutations passed on the original file and none of them caught this, because each mutated the implementation while the fixture stayed in the equal-ratio case. Mutation testing checks the code against the test; it cannot tell you the test is aimed elsewhere.

The rework, agreed with the canvas lane: one detector, not two. Resolve candidate regions by geometry first (pointer offset within the rect along the drag axis, a pixel-width leading strip deciding before-versus-append), then apply stickiness only among survivors carrying the same resolved intent, with the band computed from the competing geometry rather than by scaling a score that conflates ratio and distance. Width asserted in pointer pixels via dragToInsetInZone (#806), never inferred from a score.


The defect

With the pointer resting near a boundary between two drop zones, ownership flipped on every pixel of jitter — measured by the canvas lane as [1,2,1,2,1,2,...] across a 2px oscillation. The insertion line strobed between two targets and a drop landed wherever the pointer happened to be sampled. It had been passing only because no fixture put the pointer near a boundary.

A margin, not a dwell

Plan 04 permits either an 8-12px distance margin or a >100ms dwell.

A dwell charges latency on every deliberate move and puts wall-clock dependence into the canvas — which is the failure class this area has repeatedly produced. A margin costs nothing while the pointer moves decisively, resists only near a boundary, and is deterministic enough to assert a width on rather than wait for.

Three facts read from @dnd-kit source, not assumed

The first design here was wrong, and these are why.

1. Collision.value is a reciprocal. pointerIntersection returns 1 / distance; shapeIntersection returns intersectionRatio / distance. A fixed bonus therefore buys a pixel width that varies non-linearly with how far the pointer already is — it cannot express "8-12px" at all.

2. sortCollisions orders priority → TYPE → value, not priority → value. Containment (PointerIntersection/High) beats overlap (ShapeIntersection/Normal) before value is consulted. So a value-space band damps same-priority, same-type switches only. That is the correct scope — entering a zone outright should take effect at once — but it has to be stated rather than discovered.

3. So the band is applied in distance space and converted back through the library's own formula. Both detectors have value ∝ 1/d, so one expression covers both without branching on type:

banded = base.value × d / (d − band)

The library's number carries whatever else it encodes; the only difference from stock is one subtraction.

The constant is not in pixels of pointer movement

The comparison reduces to dIncumbent − dChallenger < band, so the constant bounds the difference of centre distances. For two zones the pointer travels between, moving x past the boundary lengthens one distance by x and shortens the other by x — the difference grows at twice the pointer's rate.

TARGET_SWITCH_BAND_CENTRE_DELTA_PX = 20 therefore buys ~10px of pointer movement. A constant of 10 would read as "10px" and deliver 5px, under the requirement's floor while appearing inside its range.

The test measures the achieved width rather than converting the constant, so the conversion is under test instead of being assumed identically in two places. Measured: 10.01px.

Verification

bandedValue and the width are covered by unit tests; 5 mutations each fail the intended assertion:

mutation fails
constant 20 → 4 (a 2px implementation) the 8-12px width assertion
scaling inverted jitter, width, and the distance-vs-constant property
Infinity guard removed the unbeatable-score case
clamp removed jitter, monotonicity, the negative/NaN guard
constant bonus instead of scaling jitter and width

Infinity matters: pointerIntersection has no zero guard, so the pointer on a centre yields exactly Infinity. Scaling it would return a finite number, leaving the incumbent weaker at the position where it is most clearly correct.

Package suite: 665 passing, typecheck and lint clean.

Scope

All three droppables the canvas registers are wired — the gap zone plus a node's insert-before and append targets. @dnd-kit/abstract and @dnd-kit/collision were already resolved transitively; declared now because this package imports them directly.

Not included: the end-to-end width assertion in the real canvas. That needs dragToInsetInZone from #806 to place the pointer at a known depth; dragUntilInsideZone stops at the first step inside a zone, which is where a correct margin implementation has deliberately not switched. The acceptance target in e2e/tests/canvas/acceptance.spec.ts belongs to the canvas lane and is untouched here.

With the pointer resting near a boundary between two drop zones, ownership
flipped on every pixel of jitter, so the insertion line strobed and a drop
landed wherever the pointer happened to be sampled.

A margin rather than a dwell: a dwell charges latency on every deliberate move
and puts wall-clock dependence into the canvas, while a margin costs nothing
while the pointer moves decisively and is deterministic enough to assert a width
on rather than wait for.

Both default detectors report a value inversely proportional to the distance
from the droppable's centre, so scaling by d / (d - band) applies the band in
distance space for either without knowing which produced the number. Adding a
constant cannot work: an inverse is non-linear, so a fixed bonus buys a pixel
width that varies with how far the pointer already is.

The constant is a bound on the DIFFERENCE of the two centre distances, which is
twice the pointer movement it buys, because the pointer recedes from one centre
while approaching the other. The test measures the achieved width instead of
converting the constant, so the conversion is under test rather than assumed
identically in both places.
…drop target

Wires the banded detector into all three droppables the canvas registers: the
gap zone, and a node's insert-before and append targets. Each wraps the default
detector rather than replacing it, so the ranking stays whatever the library
computed and the band is the single difference from stock behaviour.

Only the candidate already holding the drag is weakened, read from the drag
operation rather than tracked here, so there is no state to seed or clear when a
drag ends. The detector is built once per module: an identity that changed per
render would be reassigned on the droppable mid-drag.

@dnd-kit/abstract and @dnd-kit/collision were already resolved as transitive
dependencies; they are declared now because this package imports them directly.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dc4a50e9-8c5c-4e06-ad2e-2d0d45b9ce5e

📥 Commits

Reviewing files that changed from the base of the PR and between ec9b4c7 and ba7022f.

⛔ Files ignored due to path filters (2)
  • .changeset/canvas-drag-hysteresis.md is excluded by !.changeset/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • packages/plugin-page-builder/package.json
  • packages/plugin-page-builder/src/admin/canvas/CanvasNode.tsx
  • packages/plugin-page-builder/src/admin/canvas/DropZone.tsx
  • packages/plugin-page-builder/src/admin/canvas/hysteresis.test.ts
  • packages/plugin-page-builder/src/admin/canvas/hysteresis.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba7022f85b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

type: BLOCK_TYPE,
accept: BLOCK_TYPE,
data: { kind: "dropzone", parentId, slot, index },
collisionDetector: STICKY_COLLISION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove stale expected-failure markers for hysteresis

Once this detector keeps boundary jitter stable, both e2e/tests/canvas/acceptance.spec.ts:552-561 and e2e/tests/canvas/scenarios.spec.ts:437-448 still call test.fail(true, ...) immediately before asserting that no target transitions occurred. Playwright therefore reports the now-successful assertions as “Expected to fail, but passed,” so implementing this feature makes both root canvas E2E tests fail; update those markers and their stale capability annotations with the implementation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Accepted, and thank you for catching it — this would have turned a working feature into two red root E2E tests.

test.fail(true, ...) inverts the result, so making the assertion pass reports "Expected to fail, but passed". Both acceptance.spec.ts:552-561 and scenarios.spec.ts:437-448 carry those markers.

Those files belong to the canvas lane, and I said in this PR that I would not edit them. That is still right, but it is not sufficient — my change makes their markers wrong, which is a coordination obligation rather than a licence to leave them broken. I am raising it with that lane so the markers come off in the same change that makes them stale, and #810 stays in draft until that is arranged.

accept: BLOCK_TYPE,
disabled: dropBeforeIndex == null,
data: { kind: "dropzone", parentId, slot, index: dropBeforeIndex ?? 0 },
collisionDetector: STICKY_COLLISION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude overlapping grid targets from the hysteresis band

When append:<grid> becomes incumbent while the pointer is over grid padding or a gap and the pointer then enters a child, the grid append shape and the child's insert-before shape overlap, so both produce high-priority pointer-intersection collisions. If their centers coincide or are less than the 20px band apart, as in a single-cell or narrow nested grid, the challenger cannot become 20px closer by the triangle inequality; the boosted append target therefore remains active over the child and the drop appends inside the grid instead of inserting before that child. Avoid applying this sibling-boundary hysteresis between ancestor append and descendant insert-before targets, or bound it using the competing targets' geometry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Accepted, and it is the composition problem rather than a tuning issue.

Your triangle-inequality argument is the decisive part: when append:<grid> is incumbent and the pointer enters a child, the two shapes overlap and both report high-priority pointer intersections. If their centres are less than the band apart — a single-cell or narrow nested grid — the challenger cannot close a 20px gap no matter where the pointer goes, so the append target is permanently sticky and the drop lands inside the grid instead of before the child. That is a functional regression, not a degraded margin.

It also confirms a distinction the canvas lane and I had already agreed on and which I then failed to implement: hysteresis is a SIBLING-boundary rule, and ancestor-versus-descendant is a different question that must be settled by geometry before any stickiness applies. Regions first, stickiness only among the survivors. I built the second half without the first.

This will be part of the rework rather than a separate guard, since bolting an exclusion onto the current shape would be a third rule interacting with the two that already disagree.

Comment on lines +120 to +124
value: bandedValue(
collision.value,
centreDistance(centre, pointer),
band
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Account for changing overlap ratios in the switch margin

For ShapeIntersection collisions, collision.value is intersectionRatio / distance, and the incumbent's ratio normally shrinks while the challenger's grows as the dragged rectangle crosses between gap zones. This scaling therefore compares ratioInc / (distanceInc - band) with ratioNew / distanceNew; it only reduces to a 20px center-distance advantage when the ratios happen to be equal, as they are in the unit fixture that hardcodes both scores as 1 / distance. With a tall source leaving one narrow gap zone while entering the next, the changing ratios can overcome the band after only a couple of pointer pixels, so the promised 8–12px margin and jitter resistance disappear; calculate the decision from the competing geometry or exercise the wrapper with real shape-intersection scores.

AGENTS.md reference: AGENTS.md:L183-L186

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed by measurement, and it invalidates this PR's headline claim. I have moved #810 back to draft rather than patch around it.

You are exactly right about the mechanism. For ShapeIntersection the value is intersectionRatio / distance, and the ratios are not equal between incumbent and challenger — the incumbent's shrinks as the source leaves and the challenger's grows as it enters. My scaling therefore compares ratioInc / (dInc - band) against ratioNew / dNew, which reduces to a distance band only when the ratios coincide.

And my unit fixture made them coincide, hardcoding 1 / distance for both sides. So the test asserted the one case the mechanism does not take: gap zones are zero-height, contain no point, and therefore never win pointer containment — they compete through shape overlap, which is precisely the path where the original [1,2,1,2] flip was measured. The fixture never reached the mechanism it was written to cover, on the claim the whole change rests on.

Measured over realistic geometries with the band at 20:

source h zone h centre sep achieved
200 8 40 10.01px
60 8 40 8.22px
20 8 40 6.01px
60 40 60 4.48px

The requirement is 8-12px of pointer movement. 4.48px is below the floor, so this does not meet it — the "measured 10.01px" I put in the PR description is true only of the equal-ratio case my fixture constructed.

The fix is the one you name: decide from the competing GEOMETRY rather than by scaling a value that conflates ratio and distance. That is a design change, not an adjustment, so I would rather rework it than tune the constant — a constant chosen to rescue the worst geometry would overshoot every other one.

@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@ba7022f

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@ba7022f

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@ba7022f

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@ba7022f

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@ba7022f

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@ba7022f

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@ba7022f

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@ba7022f

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@ba7022f

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@ba7022f

nextly

npm i https://pkg.pr.new/nextly@ba7022f

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@ba7022f

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@ba7022f

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@ba7022f

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@ba7022f

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@ba7022f

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@ba7022f

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@ba7022f

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@ba7022f

commit: ba7022f

@mobeenabdullah
mobeenabdullah marked this pull request as draft August 14, 2026 10:02
@mobeenabdullah mobeenabdullah changed the title feat(plugin-page-builder): resist target switching near a zone boundary feat(plugin-page-builder): resist target switching near a zone boundary [DRAFT — design refuted, reworking] Aug 14, 2026
@github-actions github-actions Bot added type: docs Documentation only scope: plugin @nextlyhq/plugin-* packages dependencies Dependency updates (label applied by Dependabot) labels Aug 14, 2026
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

Closing this rather than leaving it open, since the approach it implements has been refuted by measurement and none of the code here survives into the fix. The branch is preserved and this is reopenable if that turns out to be wrong.

Why it cannot be finished as written. The band scales Collision.value, and shapeIntersection reports intersectionRatio / distance with the two ratios never equal between incumbent and challenger. Measured across three successive canvas geometries: 1.27px to 27.01px against an 8-12px requirement — failing in both directions, so not a tuning problem.

Asking what both attempts shared, rather than what each did: both assumed the ranking can be adjusted by modifying the score. sortCollisions orders priority → type → value, so a band in value is never consulted where candidates differ in priority or type — which is every transition between pointer containment and shape overlap, and those alternate as the pointer crosses a zone. A third attempt computing a cleverer number fails identically, pointing elsewhere.

Two further defects found here, both real and both carried forward:

  • ancestor append:<grid> versus a descendant's before: where the centres are closer than the band, the challenger cannot win by the triangle inequality, so the drop lands inside the grid instead of before the child;
  • acceptance.spec.ts and scenarios.spec.ts carry test.fail(true, ...) on the hysteresis assertions, which inverts the result — any correct implementation turns both root E2E specs red, so their removal must land in the same change.

Nothing is lost. The refutation, the measurements, the agreed rework and the constraint that forces container ownership into stage one are in tasks/left-tasks/2026-08-14-0130-b7-drag-hysteresis.md and 2026-08-14-1730-coincident-zone-rects-at-nested-boundaries.md.

The rework is one detector: stage 1 resolves candidate regions by geometry and container ownership — identical rects at a nested boundary defeat geometry alone — and stage 2 applies stickiness only among survivors with the same resolved intent, with the band computed from competing geometry rather than from a score. It starts once DropZone.tsx settles; that file moved three times today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Dependency updates (label applied by Dependabot) scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant