LearnNewsExamplesServices
Frontmatter
id18113
titlenativeDragZone can only read DOM attributes, so a business id forces useInternalId: false
stateOpen
labels
enhancementaiarchitecturegrid
assignees[]
createdAtSep 2, 2026, 5:22 PM
updatedAtOct 4, 2026, 1:04 PM
githubUrlhttps://github.com/neomjs/neo/issues/18113
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

nativeDragZone can only read DOM attributes, so a business id forces useInternalId: false

Open Backlog/active-chunk-20 enhancementaiarchitecturegrid
neo-opus-grace
neo-opus-grace commented on Sep 2, 2026, 5:22 PM

Context

Neo.component.Base#nativeDragZone (#17885) lets a component declare a native drag payload as JSON, which Neo.main.addon.NativeDragSource turns into a real DataTransfer on the main thread. The declaration is a template map — {'text/plain': 'app:{data-record-id}'} — and NativeDragSource.mjs:183 resolves every token through exactly one source:

dataTransfer.setData(type, template.replace(templateToken, (match, name) => node.getAttribute(name) ?? ''))

getAttribute, and nothing else. That is deliberate — the class comment says so, and it is what keeps the declaration JSON-safe across the worker boundary.

The consequence is a coupling nobody declared: to put a business id on the clipboard, the business id must first be written into the DOM. For a grid that means useInternalId: false, because otherwise Neo.grid.Body#getRecordId writes neo's own neo-record-N and the payload carries it instead.

Verified live on dev this session by driving a real browser drag into a real same-origin iframe: childDragEnter 1, drop fired, and both text/plain and the custom MIME arrived carrying the attribute value. The mechanism works. The coupling is what this ticket is about.

The Problem

The failure is silent. With useInternalId: true and an attribute template naming the record-id attribute, there is no error, no warning, and no signal anywhere. getAttribute returns neo-record-7, setData accepts it, the drag runs, the drop fires, and the receiver looks up an id that does not exist in its domain — so nothing happens. The gesture completes successfully and does nothing, which is the hardest class of defect to attribute to its cause.

The pairing is enforced only by prose. Two JSDoc blocks describe it. Nothing mechanical checks it. A grid can be given a nativeDragZone today, or have useInternalId flipped tomorrow, and both edits look locally correct.

useInternalId is not a free knob to switch off. internalId was adopted deliberately for stable DOM keying across grid, table, list and data views (#9070, #9071, #9072), and the record-resolution paths in the selection models, grid.Body#getRecord, getRecordFromLogicalId and list DragZone were all reworked to resolve through it. Requiring useInternalId: false asks a consumer to opt out of that architecture in order to use an unrelated feature — a drag concern reaching back into DOM identity.

Why the obvious fix does not work. The authoritative record lives in the app-worker store; the DataTransfer must be filled synchronously inside the main thread's dragstart handler. There is no time to ask the app worker anything. That is precisely why the current implementation reads attributes: the DOM is the only thing the main thread already has.

The Architectural Reality

  • src/main/addon/NativeDragSource.mjs — templateToken = /\{([\w-]+)\}/g (:8); the single resolution site is :183; register({ownerId, delegate, types, effectAllowed}) (:249) is the whole registration contract and carries no record data.
  • src/component/Base.mjs — nativeDragZone_ (:209), afterSetNativeDragZone (:647), and the generation-guarded push to the addon (:664-676). The declaration already re-issues on change, so a registration payload that carries more than templates has a delivery path that exists.
  • src/grid/Body.mjs — getRecordId is what writes neo-record-N when useInternalId is true.
  • The synchronous constraint is real and is the design's reason for existing; any fix must respect it by pushing data to main ahead of dragstart, never fetching at dragstart.

The Fix

Give a template a second resolution source that does not require the value to be in the DOM: let a token resolve a record field, with the mapping delivered to the main thread as part of the registration.

  1. Extend the nativeDragZone declaration with an explicit record-field form, distinguishable from an attribute token at parse time (so existing attribute templates keep their exact meaning — no reinterpretation of {data-record-id}).
  2. The owning component pushes a node-id → field-value map alongside the registration and refreshes it when the underlying rows change, so NativeDragSource holds the values before any dragstart and stays synchronous at resolution time.
  3. Resolution falls back to getAttribute when a token is not a declared field, preserving today's behaviour for every current consumer.
  4. A grid declaring a nativeDragZone no longer needs useInternalId: false; the two settings become independent.

Contract Ledger Matrix

Target Surface Source of Authority Behavior Fallback Docs Evidence
component.Base#nativeDragZone declaration #17885; src/component/Base.mjs:209 gains an explicit record-field token form beside the existing attribute form an undeclared token resolves via getAttribute, exactly as today nativeDragZone_ JSDoc + NativeDragSource class comment positive: a field token yields the store value with useInternalId: true; negative: an unknown token still yields the attribute, and an absent attribute still yields ''
NativeDragSource.register({ownerId, delegate, types, effectAllowed}) (:249) src/main/addon/NativeDragSource.mjs:249 accepts the field-value map; resolution at :183 consults it before getAttribute registrations without the map behave identically to today addon class comment a registration omitting the map drives an unchanged drag
the useInternalId: false pairing prose only, in two JSDoc blocks retired — the settings become independent n/a both JSDoc blocks updated to stop prescribing the pairing a grid with useInternalId: true + a field token puts the business id on the clipboard
the synchronous dragstart contract NativeDragSource design (#17885) unchanged — values are pushed on register/update, never fetched at dragstart none; a fetch at dragstart is the defect being avoided addon class comment no await on the dragstart path; the map is read from memory

Decision Record impact

none — this extends a component/main-thread addon contract inside its existing ownership. It does not touch ai/, so §1c Agent OS Structure Map is N/A.

Acceptance Criteria

  • AC-1 A nativeDragZone template can name a record field, and the resulting DataTransfer carries the store value while the owning grid has useInternalId: true. Red-first: the same declaration on dev puts neo-record-N on the clipboard.
  • AC-2 Control — an existing attribute-token declaration is byte-identical in behaviour: same MIME types, same values, no registration change required.
  • AC-3 Control — an unresolvable token still yields '' rather than the literal template text, matching ?? '' today.
  • AC-4 The dragstart path performs no asynchronous work: the field values are present in the addon before the gesture begins, and a spec asserts the resolution site reads only from memory.
  • AC-5 The values survive a row update — changing the underlying record and dragging again yields the new value, not a stale one captured at first registration.
  • AC-6 Both JSDoc blocks stop prescribing useInternalId: false, and a grid pairing useInternalId: true with a field token is exercised by a spec.

Out of Scope

  • The interim dev-mode guard. A cheaper mitigation exists: warn when a nativeDragZone template references the record-id attribute while the owning body has useInternalId: true. It makes the silent class loud without removing the coupling. Deliberately not bundled — if this ticket slips, that guard is worth filing on its own, but shipping both means building a warning for a coupling this ticket deletes.
  • Changing any default: useInternalId keeps its current default, and no existing consumer is migrated by this work.
  • The payload protocol itself (which MIME types, what prefix). That is the receiving application's contract, not the engine's.
  • The synthetic drag pipeline (draggable/), which is a separate mechanism from native DataTransfer.

Avoided Traps

  • Resolving the record at dragstart. The obvious shape — ask the app worker for the record when the drag starts — cannot work: DataTransfer must be filled synchronously inside the handler, and the store lives in another thread. Any prescription that reaches across the worker boundary at gesture time is wrong by construction. The data must already be on main.
  • Reinterpreting the existing token syntax. Making {foo} mean "field foo, else attribute foo" would silently change the meaning of every shipped declaration the first time a field and an attribute share a name. The field form must be explicit at parse time.
  • "Just document the pairing harder." It is already documented in two places, and prose did not prevent the class — the failure mode is silent, so a consumer gets no feedback that they read the docs wrong.
  • Treating this as a grid ticket. The coupling lives in the resolution rule, not in the grid. Any component with a nativeDragZone whose meaningful id is not already a DOM attribute has the same problem; the grid is simply where it was observed.

Related

  • #17885 — origin: declarative native drag sources, DataTransfer from worker config. The mechanism this extends.
  • #9070 / #9071 / #9072 — the deliberate internalId adoption for stable DOM keying that useInternalId: false opts out of.
  • #18087 / PR #18091 — a native drop into an outside iframe pane, same demo path.
  • #18099 / PR #18103 and #18104 / PR #18109 — GridDragScroll gesture cleanup around native drags out of the grid body.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T15:20:40Z; nearest neighbours are #18104 (grid drag-scroll state) and #18099, both a different mechanism. A2A in-flight claim sweep over the newest 30 messages (14:19Z–15:17Z, all read-states): no claim on nativeDragZone, NativeDragSource or useInternalId. Memory Core rationale sweep on the problem's nouns returned the internalId adoption trail (#9070-#9072) and this session's own live verification of the iframe path; no prior decision against changing the resolution rule. Own-assignment sweep: 7 open, none on this surface.

unowned-rationale: parked deliberately — the demo is tomorrow morning and this changes the exact drag path the client will exercise. Filing it now so the finding is not lost; it should be picked up after the demo, not before.

Origin Session ID: fb58e855-74b2-4367-9f83-5877f151f024

Retrieval Hint: "nativeDragZone template resolves a record field instead of a DOM attribute, useInternalId coupling, NativeDragSource synchronous dragstart"