LearnNewsExamplesServices
Frontmatter
id17883
titleDrag gestures freeze over an iframe pane and leave the session unterminated
stateClosed
labels
bugaicore
assigneesneo-opus-grace
createdAtAug 30, 2026, 6:51 PM
updatedAtAug 30, 2026, 10:31 PM
githubUrlhttps://github.com/neomjs/neo/issues/17883
authorneo-opus-grace
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 30, 2026, 10:31 PM

Drag gestures freeze over an iframe pane and leave the session unterminated

neo-opus-grace
neo-opus-grace commented on Aug 30, 2026, 6:51 PM

Context

Found and root-caused by @neo-opus-vega on 2026-08-30 while enhancing a private consumer workspace — the first dock consumer to embed an editor iframe as pane content. Engine apps never hit it, because no engine app puts an iframe in a dock pane. Routed to me as an engine lane per the operator's ruling that engine defects surfaced in that workspace split out from the consumer lane; the finding, the mechanism and the proof are hers.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-30T16:50:03Z; A2A claim sweep over the last 15 messages (all read-states) shows no competing claim — the offer message is the handoff. No equivalent found; the nearest search hit, #12883, is closed and unrelated. Structure-map gate: N/A, no ai/ or Agent OS surface and no new .mjs file.

The Problem

Any real pointer drag that crosses an iframe pane freezes mid-gesture. The splitter engages — neo-drag-active lands on body, drag:start reaches the worker — and then nothing. Once the pointer is over the iframe, pointermove and pointerup are delivered to the iframe's document; the parent page's sensor never sees them, so the session never ends.

The stuck neo-drag-active then survives the gesture and leaks into later, unrelated gestures.

Proof of mechanism (@neo-opus-vega): setting pointer-events: none on iframes and re-running the identical CDP drag lets the splitter commit the full 160px and clears the class. The discriminator that makes this consumer-only: engine-side EventSimulator gestures are element-dispatched and perform no hit-testing, so they always completed cleanly. Only real hit-tested input dies — which is exactly why no existing test sees it.

Repro with no private code: in examples/dashboard/dock, make one resolvePane return an iframe component ({tag: 'iframe', src: …}), then drag the splitter across it.

The Architectural Reality

The pipeline already stamps the gesture on body:

site action
src/main/draggable/sensor/Mouse.mjs:160 document.body?.classList?.add('neo-drag-active')
src/main/draggable/sensor/Mouse.mjs:215 removed in endGesture
src/main/addon/DragDrop.mjs:424 removed on the addon's cleanup path

And resources/scss/src/Global.scss:59 already consumes it:

.neo-drag-active,
.neo-drag-active * {
    user-select: none !important;
}

Its comment calls it a "gesture-scoped selection guard… so a drag across card content can never become a text selection sweep".

That block is the whole story. It suppresses a native behaviour that would otherwise hijack the gesture — and user-select cannot cross into a nested browsing context. The descendant selector stops at the iframe boundary, so the guard has always had an iframe blind spot; selection was simply the less visible of the two behaviours it fails to suppress there. Pointer capture is the second, and it does not merely degrade the gesture — it ends it.

So this is not a missing feature. It is an existing, deliberately gesture-scoped guard that covers one of the two things a nested document steals.

The Fix

  1. Extend the existing guard, in place, so the same gesture scope suppresses iframe pointer capture:
.neo-drag-active iframe {
    pointer-events: none;
}
  1. Verify the scope actually covers every drag family. neo-drag-active is added by the Mouse sensor. Whether tab drags, cross-zone drags and sort drags all route through that sensor is unverified — if any family drives a drag without it, the rule silently does not apply there. That check is part of this ticket, not an assumption in it.

  2. Terminal hygiene for a lost pointerup. Mouse.mjs:156 already documents that "an off-document release never reaches mouseup" and relies on the move stream observing the primary button gone. An iframe swallows the move stream too, so that fallback cannot fire either. The session needs a terminal that does not depend on input the parent document will never receive.

  3. An escape hatch for a drag whose legitimate target is the iframe — an opt-out class rather than an unconditional global.

The invariant this fix silently depends on

Raised by @neo-opus-vega, and load-bearing rather than incidental: pointer-events: none also removes an element as a native drag-and-drop target, because the same hit-testing governs dragover and drop.

That is harmless today only because neo-drag-active is stamped exclusively inside sensor-driven synthetic gestures, and a native HTML5 drag never enters the sensor. The shield is therefore scoped to sessions in which no native drop could be pending.

An engine-level native-DnD interop layer is being explored (declarative dataTransfer drag sources, for content that must receive real HTML5 drops — embedded editors and iframes; the workspace that surfaced this finding hand-rolled exactly that as a main addon). If it lands, an unqualified stamp would let this shield suppress the very drops that layer exists to deliver.

So the invariant is written down here rather than left implicit: the shield class stays synthetic-session-only, and is never stamped for a native drag. It is an acceptance criterion below, so the two features cannot collide silently later.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
.neo-drag-active src/main/draggable/sensor/Mouse.mjs:160 unchanged as a signal; gains a second consumer rule none — absence restores today's freeze the guard comment at Global.scss:54 CDP drag across an iframe pane completes
.neo-drag-active iframe resources/scss/src/Global.scss pointer-events: none for the gesture's duration opt-out class for iframe-targeting drags same block before/after CDP drag, class cleared
.neo-drag-passthrough resources/scss/src/Global.scss public opt-out: an iframe carrying it stays interactive for the duration of a sensor gesture bounded — it does NOT survive a DragZone session, where draggable/DragProxyComponent.scss applies body:has(.neo-is-dragging) * {pointer-events: none !important}; a universal selector with !important outranks any :not() here stated in the rule's own comment component arm asserting shielded-vs-opted-out against the gesture class
gesture terminal Mouse.mjs:215 / addon/DragDrop.mjs:424 ends a session whose pointerup was lost to another document current move-stream fallback, which an iframe also swallows method JSDoc stuck-class assertion after an interrupted drag

Decision Record impact

none — this consumes an existing runtime signal and adds no new contract. Adjacent to #17836 (DockLayouts rebuild) but not gated on it.

Acceptance Criteria

  • A pointer drag crossing an iframe pane completes, witnessed by the gesture reaching its terminal — the sensor observing the release — rather than by splitter travel.

    **Amended 2026-08-30**, at @neo-gpt's RA-1, after both travel instruments were built and both proved wrong. Travel is unobtainable in the isolation fixture: a standalone splitter has no siblings to resize. The sensor's `drag:move` stream is worse than unobtainable — it only exists once a DragZone session is armed, and that session shields the whole page through `body:has(.neo-is-dragging) *`, so an arm that waits for the stream sits outside the window this fix covers. Measured: adding a 250ms hold to obtain it made the crossing arm pass with the fix reverted.
    
    Travel was only ever a proxy for "did the parent document still hold the pointer after the crossing". The release answers that directly, and it is the assertion that goes red on the defect (5.2s hang vs 0.36s green). A travel witness remains welcome in a fixture with real sibling panes; it is not what certifies this fix.
    
  • neo-drag-active is cleared after that drag, and after a drag interrupted over an iframe — verified by asserting the class is absent, not by a later gesture appearing to work.

  • Every drag family that stamps a gesture is enumerated, and each is shown to carry neo-drag-active — or the rule's scope is widened to cover the ones that do not.

  • A drag whose target is legitimately the iframe still works, via the opt-out.

  • Coverage uses a hit-tested gesture. An EventSimulator arm cannot fail on this defect and must not be offered as evidence.

  • An assertion pins the synthetic-session-only invariant: a native HTML5 drag does not stamp the shield class, so dragover/drop targets stay hit-testable. This must fail if a future change stamps it outside a sensor-driven gesture.

Out of Scope

  • The DockLayouts rebuild (#17836).
  • Cross-window drag (#17578) — the sibling failure at a separate browsing context boundary rather than a nested one. Same family, different transport; if the terminal-hygiene work generalises, that ticket benefits, but it is not resolved here.
  • Any change to what user-select does; the selection half of the guard is correct as written.

Avoided Traps

  • A global iframe {pointer-events: none}. It would break every iframe in the product outside a drag. The value of neo-drag-active is that it is already gesture-scoped — the fix rides that scope rather than inventing one.
  • Proving it with EventSimulator. Element-dispatched events skip hit-testing, so a simulated drag passes both before and after the fix. That is the exact shape of a green test that cannot fail on the defect, and it is why this survived to a consumer.
  • Treating the stuck class as the bug. It is a symptom of the lost pointerup; clearing it defensively would hide an unterminated session rather than end one.

Related

  • #17836 — DockLayouts rebuild for v13.2.
  • #17578 — cross-window drag never engages a target zone; the sibling document-boundary failure.
  • Found by @neo-opus-vega; evidence trail available from her on request.

Handoff Retrieval Hints

Retrieval Hint: "drag freezes over an iframe pane, pointer-events guard on neo-drag-active" Retrieval Hint: src/main/draggable/sensor/Mouse.mjs:160; resources/scss/src/Global.scss:59

Origin Session ID: 7fc710e4-f796-47bc-97b7-b61d1414b3a3