LearnNewsExamplesServices
Frontmatter
id16623
titleA drag proxy's DOM removal dies with its zone
stateClosed
labels
bugaicore
assigneesneo-fable
createdAtAug 7, 2026, 12:13 PM
updatedAtAug 7, 2026, 2:06 PM
githubUrlhttps://github.com/neomjs/neo/issues/16623
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 7, 2026, 2:06 PM

A drag proxy's DOM removal dies with its zone

neo-fable
neo-fable commented on Aug 7, 2026, 12:13 PM

Context

Attribution correction (2026-08-07, review cycle 1): the filmed chip is NOT this defect's product. An instrumented film run at the fixed head shows every proxy removal dispatching AND settling (transport-confirmed), while a DOM identity probe names the on-camera survivor as neo-dock-stack-handle-metrics — the toolbar's STATIC stack grip (16x20 layout box carrying the large pseudo-element visual), chrome that legitimately persists until the vessel window closes. The filmed symptom's remedy is the #16626 departing overlay (frame receipt on PR #16627 shows it buried). THIS ticket stands on the engine defect below, which is real, in-tree, and unit-red-proven independent of any film frame.

Original context

Split-leaf of #16499's S-3a (full probe evidence in its r4 comment). Filmed symptom: after a cross-window stack-return commits, the grouped-drag proxy chip persists at full opacity on the emptied source vessel for ~1s until the OS window closes — 2/2 runs at film pace. An instrumented run proved the source-side dragEnd fires and completes within ~2ms of the drop, falsifying "end never fires" and "end fires late": the teardown call runs; its DOM effect never lands.

The Problem

destroyDragProxy() (src/draggable/DragZone.mjs:329-339) splits the teardown: the worker-side component is destroyed synchronously, but the main-thread DOM removal is deferred —

me.timeout(me.moveInMainThread ? 0 : 30).then(() => {
    Neo.applyDeltas(me.windowId, [{action: 'removeNode', id}])
});

core.Base#timeout() registers in the instance's async-reject set, and core.Base#destroy() clears the timer and rejects the promise (Neo.isDestroyed). When the zone (or its owner toolbar chain) is destroyed inside that 0–30ms window, the .then never runs and the removeNode delta is never dispatched — the proxy's DOM node is orphaned in the source window with no owner left to remove it.

The stack-return hits this deterministically: the probe timeline shows documents-adopted (which un-projects the source vessel's chrome, destroying the toolbar + its sort zone) landing in the SAME millisecond as dragEnd-done — the zone dies well inside the 30ms deferral every time.

The Architectural Reality

  • The deferral exists to let custom animations settle ("Override for using custom animations"); the cancellation is an accident of riding the instance-scoped timeout, not a design intent — a cleanup delta is exactly the kind of dispatch that must survive its dispatcher.
  • Every DragZone consumer in every closing-window scenario inherits the class: dock vessels, dialog drags in popups, any drag whose zone is torn down within the deferral window.
  • The unhandled rejection is invisible by design (Neo.isDestroyed rejections are globally suppressed) — nothing fails loudly, matching the field evidence.

The Fix

Guarantee the dispatch outlives the zone: keep the deferred path, and dispatch on the rejection path too —

me.timeout(me.moveInMainThread ? 0 : 30)
    .catch(() => null)
    .then(() => Neo.applyDeltas(windowId, [{action: 'removeNode', id}]))
    .catch(reason => {
        reason !== undefined && console.error('DragZone: proxy removal delta failed', {id, reason, windowId})
    });

(binding windowId/id locally so the dispatch does not read the destroyed instance). A window that closed in the interim makes applyDeltas reject with bare undefined; the detached cleanup chain owns that expected terminal outcome silently, while a reasoned failure remains visible.

Acceptance Criteria

  • Unit witness, red-proven at the pre-fix head: dragEnd() followed by destroy() within the deferral window still dispatches exactly one removeNode delta for the proxy id (spy on the delta surface; deterministic, no wall-clock races).
  • Normal-path behavior unchanged: without a destroy, exactly one deferred dispatch as today.
  • The filmed receipt RETIRED by the attribution correction above: the filmed chip is the static stack grip, not this proxy — its presentation remedy is #16626 (receipt already on PR #16627). This ticket's evidence is complete at the unit layer (the race and both terminal outcomes are fully reproducible in-process).

Out of Scope

  • The closing vessel's terminal presentation (#16499 S-3b — app-side departing state, separate leaf).
  • Phase ordering of the transfer (scene-4-pinned contract).

Related

#16499 (parent — S-3a probe r4 with the timeline), #16621 / PR #16622 (sibling leaf: the fast-path fix that already compresses the corpse window this defect lives in).

Origin Session ID: 84d669f4-2271-4d6a-8878-45e8754be6b3

Retrieval Hint: query_raw_memories("drag proxy removeNode timeout rejected destroy orphan dying window")

tobiu referenced in commit 0eb4539 - "fix: The proxy removal delta outlives its zone (#16623) (#16624) on Aug 7, 2026, 2:06 PM
tobiu closed this issue on Aug 7, 2026, 2:06 PM