Skip to content

Remove the scroll listener with the capture flag it was registered with - #284

Open
reorx wants to merge 1 commit into
recogito:mainfrom
reorx:fix/scroll-listener-capture-flag
Open

Remove the scroll listener with the capture flag it was registered with#284
reorx wants to merge 1 commit into
recogito:mainfrom
reorx:fix/scroll-listener-capture-flag

Conversation

@reorx

@reorx reorx commented Aug 17, 2026

Copy link
Copy Markdown

Fixes #283.

The bug

base-renderer.ts registers its scroll listener in the capture phase but removes it without the flag:

// :191
document.addEventListener('scroll', onScroll, { capture: true, passive: true });

// :232 — before this PR
document.removeEventListener('scroll', onScroll);

removeEventListener only matches a registration whose capture flag is equal, so that call was a no-op: every destroy()ed renderer left its scroll listener attached to the document. Each orphan keeps calling redraw(true) — a forced, full highlight-layer rebuild — on every scroll event in the page, so the cost accumulates over a session with every mount/destroy cycle (collapsing a panel, re-creating an annotator, client-side route changes, several annotators on one page).

The change

One line, plus a note so it does not regress:

-    document.removeEventListener('scroll', onScroll);
+    // Note: the capture flag must match the one used on registration,
+    // otherwise this call matches no listener and silently does nothing.
+    document.removeEventListener('scroll', onScroll, { capture: true });

The test

test/rendering/base-renderer.test.ts drives createRenderer with a stub painter/state and asserts the observable behaviour rather than the listener bookkeeping:

  • control — a scroll event does reach the painter while the renderer is mounted, so the harness is known to be live;
  • regression — after destroy(), a scroll event must not reach the painter.

Verified in both directions: the regression test fails on main (painter.redraw is called once after destroy()) and passes with the fix. The control test passes either way.

npx vitest run in packages/text-annotator → 5 passed. tsc -p tsconfig.build.json clean. package-lock.json deliberately untouched.

Not included

onResize.clear() is commented out two lines below (base-renderer.ts:234), so a resize debounced just before destroy() can still fire afterwards and run store.recalculatePositions() + redraw() against a destroyed painter. That one needs a clear() on the local debounce helper (the current implementation does not expose one), so it felt out of scope here — happy to follow up if you want it fixed too.

`destroy()` called `document.removeEventListener('scroll', onScroll)` while
the listener had been registered with `{ capture: true }`. removeEventListener
only matches a registration whose capture flag is equal, so the call was a
no-op and every destroyed renderer left its scroll listener on the document.

Each orphan keeps running `redraw(true)` — a forced, full highlight-layer
rebuild — on every scroll event in the page, so the cost accumulates with
every mount/destroy cycle (collapsing a panel, re-creating an annotator,
client-side route changes).

Adds a regression test that scrolls after destroy and asserts the painter is
not asked to redraw. It fails without the one-line change above.

Fixes recogito#283

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oleksandr-danylchenko

Copy link
Copy Markdown
Contributor

Awesome finding! Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

destroy() leaks the document scroll listener: removeEventListener omits the capture flag

2 participants