Skip to content

[Bug]: PDFScriptingManager (viewer components) adds a window listener it never removes, keeping every EventBus alive #22133

Description

@AlexAndBear

Attach (recommended) or Link to PDF file

Not needed, no document is involved.

Web browser and its version

Not browser-specific; reproduced with pdfjs-dist's web/pdf_viewer.mjs in Node.js 24 with happy-dom, same in Chromium 153 with the viewer components.

Operating system and its version

macOS 26.5

PDF.js version

6.4.299

Is the bug present in the latest PDF.js version?

Yes

Is a browser extension

No

Steps to reproduce the problem

Using the viewer components (pdf_viewer.mjs), create a PDFScriptingManager without externalServices (the documented way, it then creates the GenericScripting itself), tear it down and dispatch the sandbox event on window:

import { EventBus, PDFScriptingManager } from 'pdfjs-dist/web/pdf_viewer.mjs'

async function openAndClose(name) {
  const eventBus = new EventBus()
  eventBus.on('updatefromsandbox', () => console.log(`${name}: eventBus still receives updatefromsandbox`))
  const manager = new PDFScriptingManager({ eventBus })
  await manager.setDocument(null) // like a viewer that is torn down
}
await openAndClose('viewer 1')
await openAndClose('viewer 2')
await openAndClose('viewer 3')
window.dispatchEvent(new CustomEvent('updatefromsandbox', { detail: {} }))

What is the expected behavior?

Nothing is logged: a scripting manager that was torn down (or garbage collected) no longer listens on window, and nothing keeps its EventBus alive.

What went wrong?

All three managers log:

viewer 1: eventBus still receives updatefromsandbox
viewer 2: eventBus still receives updatefromsandbox
viewer 3: eventBus still receives updatefromsandbox

PDFScriptingManagerComponents adds the listener in its constructor when no externalServices are given:

if (!options.externalServices) {
  window.addEventListener("updatefromsandbox", event => {
    options.eventBus.dispatch("updatefromsandbox", { source: window, detail: event.detail });
  });
}

It is never removed: #destroyScripting aborts the manager's own #eventAC, but this listener is registered outside of it, and there is no destroy() that could remove it either. In a single-page app that opens documents one after another (new PDFViewer + PDFScriptingManager + EventBus per document), every closed document stays reachable through window → listener → eventBus → its listeners (viewer, link service, …). It also forwards sandbox events of the current document to all previous event buses.

A fix could register the listener with the manager's abort signal (or in setDocument instead of the constructor, removed in #destroyScripting). The workaround for embedders is to pass their own externalServices.createScripting, which skips the listener.

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions