LearnNewsExamplesServices
Frontmatter
id17885
titleDeclarative native drag sources: DataTransfer from worker config
stateClosed
labels
enhancementaiarchitecture
assigneesneo-opus-vega
createdAtAug 30, 2026, 7:06 PM
updatedAtAug 31, 2026, 12:34 AM
githubUrlhttps://github.com/neomjs/neo/issues/17885
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 31, 2026, 12:34 AM

Declarative native drag sources: DataTransfer from worker config

Closed v13.2.0/archive-v13-2-0-chunk-17 enhancementaiarchitecture
neo-opus-vega
neo-opus-vega commented on Aug 30, 2026, 7:06 PM

Context

A private consumer workspace (the first to embed a main-thread editor inside an iframe as dock-pane content) needed to drag entity rows out of a Neo grid and drop them INTO that editor, carrying an entity id. Neither half is possible with the engine's drag system today, so the consumer hand-rolled a ~200-line main-thread addon that arms native HTML5 drags and fills DataTransfer itself. That addon is a correct, well-documented reference implementation — and it is exactly the kind of consumer main-thread code the engine exists to make unnecessary. This ticket upstreams the mechanism (declarative native drag sources); the consumer's mime vocabulary stays consumer-side.

The Problem

Two facts, both browser-spec, not engine choices:

  1. DataTransfer exists only synchronously inside a native dragstart handler on the main thread. The app worker receives a serialized copy after the drag data store is sealed — nothing worker-side can ever fill a payload.
  2. Synthetic pointer streams do not enter foreign browsing contexts. A pointer-based drag can never deliver a drop into an iframe's content, another browser window's foreign document, or the OS. Native HTML5 DnD crosses all three.

The engine's drag system is fully synthetic (src/main/draggable/README.md: Shopify-inspired, because handlers live in the App Worker) — grep -r dataTransfer src/ returns zero hits. Worse, synthetic and native are mutually exclusive per gesture: once a sensor claims a gesture, it installs a document-level dragstart → preventDefault for the gesture's remainder, so a native drag on a claimed node looks armed and is dead. Today the two worlds are partitioned only by an implicit class gate — an accident of implementation, not a contract the engine guards.

The Architectural Reality

  • src/main/draggable/sensor/Base.mjs:21-23 — dragTargetClasses: ['neo-draggable', 'neo-resizable'] defines the (implicit) partition.
  • src/main/draggable/sensor/Mouse.mjs:135 — testPathInclusion claims the gesture; :162 installs document.addEventListener('dragstart', preventDefault); :217 removes it.
  • src/grid/Body.mjs:1155 — pooled rows expose only getRowClass; there is no per-node attribute hook, so a consumer cannot even declare draggable on grid cells from the worker vdom. (Dynamic per-gesture arming turns out to be the better pattern anyway: a static draggable attribute breaks text selection inside cells.)
  • Neo↔Neo cross-window drags already ride the synthetic system over the SharedWorker (examples/dashboard/crossWindow); native DnD is NOT for that. Its unique territory is genuinely foreign content: non-Neo iframes, other apps, the OS.

The consumer's reference implementation (private; available on request) proves the full working recipe: capture-phase mousedown → check the sensor gate → arm draggable on the source node → fill DataTransfer synchronously in dragstart from node attributes → disarm on dragend/mouseup. It also documents a real-world constraint the API must expose: effectAllowed is load-bearing for some drop targets (a receiving editor's dragover branched on move and read getData, which is empty during dragover by spec — only copy armed the drop).

The Fix

A new main-thread addon, src/main/addon/NativeDragSource.mjs (structural pre-flight: sibling precedent src/main/addon/DragDrop.mjs and ~20 addon siblings), driven by a worker-declared, JSON-only config — no consumer code ever runs on the main thread:

// component config (worker side), names indicative:
nativeDragZone: {
    delegate     : '.my-drag-source [data-record-id]',
    effectAllowed: 'copy',
    types        : {
        'application/x-entity-id': '{data-record-id}',
        'text/plain'             : 'entity:{data-record-id}',
        'text/uri-list'          : 'entity:{data-record-id}'
    }
}
  • Payloads are templates over the source node's attributes/dataset — main does string substitution only. The declaration travels as JSON; the domain vocabulary stays in app configs.
  • Addon lifecycle per gesture: capture-phase mousedown (skip non-primary / ctrl / meta) → consult the sensor gate → arm draggable → dragstart fills DataTransfer from templates + sets effectAllowed → dragend/mouseup disarm, leaving the DOM as vdom deltas expect.
  • The partition becomes an explicit contract: sensors skip nodes registered as native sources, and the sensor's dragstart → preventDefault explicitly spares them (guard in sensor/Mouse.mjs, replacing the current by-luck class partition).
  • Optional worker observability: nativeDragStart / nativeDragEnd component events echoing the filled types.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
src/main/addon/NativeDragSource.mjs (new) this ticket declarative arm/fill/disarm per gesture addon not loaded → no native drags, no errors drag&drop guide unit + e2e
nativeDragZone component config (new) this ticket JSON declaration forwarded to the addon absent → surface inert to native DnD same unit
sensor/Mouse.mjs gesture claim (:135, :162) existing behavior additionally spares native-registered nodes unchanged for all current surfaces same regression: existing drag suites green
body.neo-drag-active (from #17883) #17883 stamped for synthetic sessions ONLY — a native drag must never stamp it, or the iframe shield would eat legitimate native drops n/a #17883 asserted by test here

Decision Record impact

none

Acceptance Criteria

  • A component declaring nativeDragZone produces a native drag whose DataTransfer carries every declared type with attribute-template substitution — with zero consumer main-thread code.
  • effectAllowed is configurable and applied.
  • The sensor never claims a gesture on a registered native-source node, and such gestures never receive the sensor's dragstart → preventDefault (explicit guard + test).
  • draggable is armed per gesture and removed on dragend/mouseup (DOM returns to vdom-expected state; asserted).
  • body.neo-drag-active is never present during a native drag session (the #17883 shield invariant, asserted by test).
  • Existing synthetic drag behavior unchanged: current drag/sort/splitter suites stay green.
  • Drag&drop guide gains the two-worlds contract section (synthetic = worker world; native = foreign-content interop; partition rule).

Out of Scope

  • Native drop zones / receive side (reading DataTransfer on drop, file drops) — natural follow-up, separate ticket.
  • Iframe-as-Neo-context: an iframe that loads a Neo main bootstrap and registers with the shared App Worker becomes one more context in the synthetic drag topology (like torn-out windows) — records instead of mime strings. Deliberate future direction, not this ticket.
  • Any change to Neo↔Neo cross-window transport — that stays synthetic over the SharedWorker.

Avoided Traps

  • Replacing synthetic DD with HTML5 DnD: loses per-frame proxy control, FLIP, touch support, and worker-side record passing. Rejected — the synthetic system is right for the Neo world.
  • Leaving this consumer-land: every consumer embedding foreign content re-rolls a capture-phase global-listener main addon (the most dangerous consumer code class), and the sensor partition can never be a contract from outside the engine.
  • Main-thread payload callbacks: would inject app code into main. Attribute templates keep the whole declaration JSON.
  • Using native DnD for cross-window Neo drags: regression vs the worker path; explicitly out of scope.

Related

  • #17883 — iframe shield during synthetic drags (the coupled invariant above; found in the same consumer investigation)
  • #17578 — synthetic cross-window coordinator defect (adjacent territory, synthetic side)
  • #17836 — DockLayouts epic (the dock is a primary consumer of the partition contract)
  • #17884 — consumer-workspace path lane (same consumer-repo investigation family)

Live latest-open sweep: latest 20 open issues checked 2026-08-30 ~17:05 UTC — no equivalent (closest: #17883/#17578, different scope). A2A claim sweep (status:all, last hour): no overlapping claim. Historical mirror grep (resources/content/issues|discussions, fresher than the KB per #17834): two mentions, both closed, different scope (#14850, #16497).

Origin Session ID: 42a3062b-8f26-424a-ba17-f3f2db4c1d0d

Retrieval Hint: "native DnD interop dataTransfer declarative drag source iframe editor" · query_recent_turns around 2026-08-30 (splitter-freeze root cause → CnetDrag exploration)