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
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"
Context
Ada's defect-note (2026-10-16 15:01Z, while reviewing #19804): a unit probe runs
initVnode(), setsmounted = trueand callsdestroy()in one tick. On dev3b3b26fff1this raises an unhandledTypeError: Cannot read properties of undefined (reading 'horizontalScrollbar')atScrollManager.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#destroydeletes every writable own property exceptdestroyand_id(src/core/Base.mjs:534–541). A grid destroyed beforegetAddonresolves therefore resumes its register arms on a dead instance:ScrollManager'supdateDragScrollAddon,updateRowScrollPinningAddon,updateGridRowHoverSyncAddonandupdateGridHorizontalScrollSyncAddonreadme.gridContainer.*and throw (Ada's receipt).VerticalScrollbar#addScrollSyncreadsme.parent.view.idafter its await the same way.Container#addResizeObserverbuilds its payload fromme.id, which survives destroy, so it throws nothing: it registers a holder for the destroyed grid.Container#destroyhas already sent its unregister synchronously (src/grid/Container.mjs:1106), so nothing ever removes the late holder.2. Registrations no destroy retires.
Container#destroydestroys itsscrollManagerandverticalScrollbardirectly (Container.mjs:1101–1102), so neither passes through an unmount.ScrollManager#destroyretires onlyGridRowScrollPinningandGridHorizontalScrollSync(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).ScrollSync.VerticalScrollbarhas nodestroyoverride.Each main-thread registration keeps listeners in its addon's
registrationsmap.GridDragScrollalso keeps the grid's body and container elements there (src/main/addon/GridDragScroll.mjs:379–406; likewiseGridRowHoverSync.mjs:116–137andScrollSync.mjs:149–184).The Architectural Reality
test/playwright/unit/grid/ResizeObserverTeardown.spec.mjs)ScrollManager'smounted_lifecycle, which unregisters on unmount to prevent leaks. Destroy bypasses unmount, so this lifecycle never sees it.me.id, which_idkeeps, and thewindowIdargument.ScrollManager#destroyalready relies on this. The five grid addons'unregistermethods each ignore an id they do not hold.passSizeToBodyreturns once the grid is gone, because "viewresolves nothing once the grid is gone" (#19661).addResizeObserverto registerme.view.idas well, and at its head the same probe throws at that read. Whichever PR lands second keeps the guard through its rebase.The Fix
getAddonskips a destroyed instance (me.isDestroyed). Unregister arms are unchanged.ScrollManager#destroyalso retiresGridDragScrollandGridRowHoverSync.VerticalScrollbargets adestroythat retires itsScrollSync.Acceptance Criteria
GridDragScroll,ScrollSyncand the lateResizeObserverholder.ResizeObserverTeardown.spec.mjsstays green.Out of Scope
me.view.idread; its author carries the guard at rebase.Related
#19804 · #19794 ·
#19661Live 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"