LearnNewsExamplesServices
Frontmatter
id19820
titleA destroyed grid leaves main-thread addon registrations behind
stateClosed
labels
bugaigrid
assigneesneo-opus-grace
createdAtOct 16, 2026, 5:25 PM
updatedAtOct 16, 2026, 8:05 PM
githubUrlhttps://github.com/neomjs/neo/issues/19820
authorneo-opus-grace
commentsCount0
parentIssue19794
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 16, 2026, 8:05 PM
milestonev13.3

A destroyed grid leaves main-thread addon registrations behind

neo-opus-grace
neo-opus-grace commented on Oct 16, 2026, 5:25 PM

Context

Ada's defect-note (2026-10-16 15:01Z, while reviewing #19804): a unit probe runs initVnode(), sets mounted = true and calls destroy() in one tick. On dev 3b3b26fff1 this raises an unhandled TypeError: Cannot read properties of undefined (reading 'horizontalScrollbar') at ScrollManager.updateDragScrollAddon (src/grid/ScrollManager.mjs:286). I promoted it by triage for v13.3 grid polish, because a source read of the same mechanism shows registrations also outlive every ordinary destroy. Only Ada's probe has been run. Everything else below comes from reading the source; the fix's unit witnesses show it red first.

The Problem

A grid registers up to six main-thread addons on mount, each through await Neo.currentWorker.getAddon(). Two defects follow.

1. Registrations that resume after destroy. Base#destroy deletes every writable own property except destroy and _id (src/core/Base.mjs:534–541). A grid destroyed before getAddon resolves therefore resumes its register arms on a dead instance:

  • ScrollManager's updateDragScrollAddon, updateRowScrollPinningAddon, updateGridRowHoverSyncAddon and updateGridHorizontalScrollSyncAddon read me.gridContainer.* and throw (Ada's receipt). VerticalScrollbar#addScrollSync reads me.parent.view.id after its await the same way.
  • Container#addResizeObserver builds its payload from me.id, which survives destroy, so it throws nothing: it registers a holder for the destroyed grid. Container#destroy has already sent its unregister synchronously (src/grid/Container.mjs:1106), so nothing ever removes the late holder.

2. Registrations no destroy retires. Container#destroy destroys its scrollManager and verticalScrollbar directly (Container.mjs:1101–1102), so neither passes through an unmount. ScrollManager#destroy retires only GridRowScrollPinning and GridHorizontalScrollSync (ScrollManager.mjs:156–160). That leaves three registrations behind on every destroy:

  • GridDragScroll, which is on by default.
  • GridRowHoverSync, registered when there are locked columns (Container.mjs:1568).
  • The vertical scrollbar's ScrollSync. VerticalScrollbar has no destroy override.

Each main-thread registration keeps listeners in its addon's registrations map. GridDragScroll also keeps the grid's body and container elements there (src/main/addon/GridDragScroll.mjs:379–406; likewise GridRowHoverSync.mjs:116–137 and ScrollSync.mjs:149–184).

The Architectural Reality

  • Design authority, in two records:
    • The teardown spec's summary: "A grid that goes away must release the native ResizeObserver it caused." (test/playwright/unit/grid/ResizeObserverTeardown.spec.mjs)
    • ScrollManager's mounted_ lifecycle, which unregisters on unmount to prevent leaks. Destroy bypasses unmount, so this lifecycle never sees it.
  • The unregister arms are safe after destroy. They need only me.id, which _id keeps, and the windowId argument. ScrollManager#destroy already relies on this. The five grid addons' unregister methods each ignore an id they do not hold.
  • Precedent: passSizeToBody returns once the grid is gone, because "view resolves nothing once the grid is gone" (#19661).
  • #19804 rewrites addResizeObserver to register me.view.id as well, and at its head the same probe throws at that read. Whichever PR lands second keeps the guard through its rebase.

The Fix

  • In the six methods above, each register arm that resumes after getAddon skips a destroyed instance (me.isDestroyed). Unregister arms are unchanged.
  • ScrollManager#destroy also retires GridDragScroll and GridRowHoverSync.
  • VerticalScrollbar gets a destroy that retires its ScrollSync.
  • No config or API change.

Acceptance Criteria

  • A grid mounted and destroyed in the same tick raises no unhandled rejection.
  • After any destroy, whether of a mounted grid or one racing its mount, every addon registration the grid made has been retired: per addon and id, registers minus unregisters is at most 0. Red on dev for GridDragScroll, ScrollSync and the late ResizeObserver holder.
  • A mounted grid that is still alive registers the same addons as today (positive control).
  • ResizeObserverTeardown.spec.mjs stays green.

Out of Scope

  • #19804's own me.view.id read; its author carries the guard at rebase.
  • The main-thread addons themselves; they unregister correctly when told.

Related

#19804 · #19794 · #19661

Live latest-open sweep: latest 20 open issues at 15:24Z, no equivalent. A2A claim sweep: last 30 messages, no claim on this scope. MC sweep: "grid destroyed addon registration never unregistered GridDragScroll ScrollManager destroy leak horizontalScrollbar undefined TypeError after mount", 6 results. The January 2026 mounted_ design covers unmount, and no prior decision covers destroy. Own-assignment sweep: 4 open, none overlapping.

Origin Session ID: bb2e8968-a6d6-4ef7-a4ed-5ab3157b1ac8 Retrieval Hint: "grid destroy addon registrations GridDragScroll ScrollSync ResizeObserver holder after destroy"

tobiu referenced in commit 5d18500 - "fix(grid): a destroyed grid leaves no addon registration behind (#19820) (#19821) on Oct 16, 2026, 8:05 PM
tobiu closed this issue on Oct 16, 2026, 8:05 PM