diff --git a/test/unit/event_utils_spec.js b/test/unit/event_utils_spec.js index 2bb1ff822..0923c8aa6 100644 --- a/test/unit/event_utils_spec.js +++ b/test/unit/event_utils_spec.js @@ -143,6 +143,60 @@ describe("event_utils", function () { expect(onceCount).toEqual(1); }); + it("dispatch event to handlers with/without 'signal' option, aborted *before* dispatch", function () { + const eventBus = new EventBus(); + const ac = new AbortController(); + let multipleCount = 0, + noneCount = 0; + + eventBus.on("test", function () { + multipleCount++; + }); + eventBus.on( + "test", + function () { + noneCount++; + }, + { signal: ac.signal } + ); + + ac.abort(); + + eventBus.dispatch("test"); + eventBus.dispatch("test"); + eventBus.dispatch("test"); + + expect(multipleCount).toEqual(3); + expect(noneCount).toEqual(0); + }); + + it("dispatch event to handlers with/without 'signal' option, aborted *after* dispatch", function () { + const eventBus = new EventBus(); + const ac = new AbortController(); + let multipleCount = 0, + onceCount = 0; + + eventBus.on("test", function () { + multipleCount++; + }); + eventBus.on( + "test", + function () { + onceCount++; + }, + { signal: ac.signal } + ); + + eventBus.dispatch("test"); + ac.abort(); + + eventBus.dispatch("test"); + eventBus.dispatch("test"); + + expect(multipleCount).toEqual(3); + expect(onceCount).toEqual(1); + }); + it("should not re-dispatch to DOM", async function () { if (isNodeJS) { pending("Document is not supported in Node.js."); diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index c9205ec9e..481a3e1e4 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -50,7 +50,7 @@ import { PresentationModeState } from "./ui_utils.js"; class AnnotationLayerBuilder { #onAppend = null; - #onPresentationModeChanged = null; + #eventAbortController = null; /** * @param {AnnotationLayerBuilderOptions} options @@ -155,13 +155,15 @@ class AnnotationLayerBuilder { if (this.linkService.isInPresentationMode) { this.#updatePresentationModeState(PresentationModeState.FULLSCREEN); } - if (!this.#onPresentationModeChanged) { - this.#onPresentationModeChanged = evt => { - this.#updatePresentationModeState(evt.state); - }; + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this._eventBus?._on( "presentationmodechanged", - this.#onPresentationModeChanged + evt => { + this.#updatePresentationModeState(evt.state); + }, + { signal: this.#eventAbortController.signal } ); } } @@ -169,13 +171,8 @@ class AnnotationLayerBuilder { cancel() { this._cancelled = true; - if (this.#onPresentationModeChanged) { - this._eventBus?._off( - "presentationmodechanged", - this.#onPresentationModeChanged - ); - this.#onPresentationModeChanged = null; - } + this.#eventAbortController?.abort(); + this.#eventAbortController = null; } hide() { diff --git a/web/app.js b/web/app.js index f288c2adf..491761d69 100644 --- a/web/app.js +++ b/web/app.js @@ -157,7 +157,7 @@ const PDFViewerApplication = { url: "", baseUrl: "", _downloadUrl: "", - _boundEvents: Object.create(null), + _eventBusAbortController: null, _windowAbortController: null, documentInfo: null, metadata: null, @@ -1832,75 +1832,87 @@ const PDFViewerApplication = { }, bindEvents() { - const { eventBus, _boundEvents } = this; + if (this._eventBusAbortController) { + return; + } + this._eventBusAbortController = new AbortController(); - _boundEvents.beforePrint = this.beforePrint.bind(this); - _boundEvents.afterPrint = this.afterPrint.bind(this); + const { + eventBus, + _eventBusAbortController: { signal }, + } = this; - eventBus._on("resize", webViewerResize); - eventBus._on("hashchange", webViewerHashchange); - eventBus._on("beforeprint", _boundEvents.beforePrint); - eventBus._on("afterprint", _boundEvents.afterPrint); - eventBus._on("pagerender", webViewerPageRender); - eventBus._on("pagerendered", webViewerPageRendered); - eventBus._on("updateviewarea", webViewerUpdateViewarea); - eventBus._on("pagechanging", webViewerPageChanging); - eventBus._on("scalechanging", webViewerScaleChanging); - eventBus._on("rotationchanging", webViewerRotationChanging); - eventBus._on("sidebarviewchanged", webViewerSidebarViewChanged); - eventBus._on("pagemode", webViewerPageMode); - eventBus._on("namedaction", webViewerNamedAction); - eventBus._on("presentationmodechanged", webViewerPresentationModeChanged); - eventBus._on("presentationmode", webViewerPresentationMode); + eventBus._on("resize", webViewerResize, { signal }); + eventBus._on("hashchange", webViewerHashchange, { signal }); + eventBus._on("beforeprint", this.beforePrint.bind(this), { signal }); + eventBus._on("afterprint", this.afterPrint.bind(this), { signal }); + eventBus._on("pagerender", webViewerPageRender, { signal }); + eventBus._on("pagerendered", webViewerPageRendered, { signal }); + eventBus._on("updateviewarea", webViewerUpdateViewarea, { signal }); + eventBus._on("pagechanging", webViewerPageChanging, { signal }); + eventBus._on("scalechanging", webViewerScaleChanging, { signal }); + eventBus._on("rotationchanging", webViewerRotationChanging, { signal }); + eventBus._on("sidebarviewchanged", webViewerSidebarViewChanged, { signal }); + eventBus._on("pagemode", webViewerPageMode, { signal }); + eventBus._on("namedaction", webViewerNamedAction, { signal }); + eventBus._on("presentationmodechanged", webViewerPresentationModeChanged, { + signal, + }); + eventBus._on("presentationmode", webViewerPresentationMode, { signal }); eventBus._on( "switchannotationeditormode", - webViewerSwitchAnnotationEditorMode + webViewerSwitchAnnotationEditorMode, + { signal } ); eventBus._on( "switchannotationeditorparams", - webViewerSwitchAnnotationEditorParams + webViewerSwitchAnnotationEditorParams, + { signal } ); - eventBus._on("print", webViewerPrint); - eventBus._on("download", webViewerDownload); - eventBus._on("firstpage", webViewerFirstPage); - eventBus._on("lastpage", webViewerLastPage); - eventBus._on("nextpage", webViewerNextPage); - eventBus._on("previouspage", webViewerPreviousPage); - eventBus._on("zoomin", webViewerZoomIn); - eventBus._on("zoomout", webViewerZoomOut); - eventBus._on("zoomreset", webViewerZoomReset); - eventBus._on("pagenumberchanged", webViewerPageNumberChanged); - eventBus._on("scalechanged", webViewerScaleChanged); - eventBus._on("rotatecw", webViewerRotateCw); - eventBus._on("rotateccw", webViewerRotateCcw); - eventBus._on("optionalcontentconfig", webViewerOptionalContentConfig); - eventBus._on("switchscrollmode", webViewerSwitchScrollMode); - eventBus._on("scrollmodechanged", webViewerScrollModeChanged); - eventBus._on("switchspreadmode", webViewerSwitchSpreadMode); - eventBus._on("spreadmodechanged", webViewerSpreadModeChanged); - eventBus._on("documentproperties", webViewerDocumentProperties); - eventBus._on("findfromurlhash", webViewerFindFromUrlHash); - eventBus._on("updatefindmatchescount", webViewerUpdateFindMatchesCount); - eventBus._on("updatefindcontrolstate", webViewerUpdateFindControlState); + eventBus._on("print", webViewerPrint, { signal }); + eventBus._on("download", webViewerDownload, { signal }); + eventBus._on("firstpage", webViewerFirstPage, { signal }); + eventBus._on("lastpage", webViewerLastPage, { signal }); + eventBus._on("nextpage", webViewerNextPage, { signal }); + eventBus._on("previouspage", webViewerPreviousPage, { signal }); + eventBus._on("zoomin", webViewerZoomIn, { signal }); + eventBus._on("zoomout", webViewerZoomOut, { signal }); + eventBus._on("zoomreset", webViewerZoomReset, { signal }); + eventBus._on("pagenumberchanged", webViewerPageNumberChanged, { signal }); + eventBus._on("scalechanged", webViewerScaleChanged, { signal }); + eventBus._on("rotatecw", webViewerRotateCw, { signal }); + eventBus._on("rotateccw", webViewerRotateCcw, { signal }); + eventBus._on("optionalcontentconfig", webViewerOptionalContentConfig, { + signal, + }); + eventBus._on("switchscrollmode", webViewerSwitchScrollMode, { signal }); + eventBus._on("scrollmodechanged", webViewerScrollModeChanged, { signal }); + eventBus._on("switchspreadmode", webViewerSwitchSpreadMode, { signal }); + eventBus._on("spreadmodechanged", webViewerSpreadModeChanged, { signal }); + eventBus._on("documentproperties", webViewerDocumentProperties, { signal }); + eventBus._on("findfromurlhash", webViewerFindFromUrlHash, { signal }); + eventBus._on("updatefindmatchescount", webViewerUpdateFindMatchesCount, { + signal, + }); + eventBus._on("updatefindcontrolstate", webViewerUpdateFindControlState, { + signal, + }); if (AppOptions.get("pdfBug")) { - _boundEvents.reportPageStatsPDFBug = reportPageStatsPDFBug; - - eventBus._on("pagerendered", _boundEvents.reportPageStatsPDFBug); - eventBus._on("pagechanging", _boundEvents.reportPageStatsPDFBug); + eventBus._on("pagerendered", reportPageStatsPDFBug, { signal }); + eventBus._on("pagechanging", reportPageStatsPDFBug, { signal }); } if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { - eventBus._on("fileinputchange", webViewerFileInputChange); - eventBus._on("openfile", webViewerOpenFile); + eventBus._on("fileinputchange", webViewerFileInputChange, { signal }); + eventBus._on("openfile", webViewerOpenFile, { signal }); } if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { - // The `unbindEvents` method is unused in MOZCENTRAL builds, - // hence we don't need to unregister these event listeners. eventBus._on( "annotationeditorstateschanged", - webViewerAnnotationEditorStatesChanged + webViewerAnnotationEditorStatesChanged, + { signal } ); - eventBus._on("reporttelemetry", webViewerReportTelemetry); + eventBus._on("reporttelemetry", webViewerReportTelemetry, { signal }); } }, @@ -2049,62 +2061,8 @@ const PDFViewerApplication = { }, unbindEvents() { - if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { - throw new Error("Not implemented: unbindEvents"); - } - const { eventBus, _boundEvents } = this; - - eventBus._off("resize", webViewerResize); - eventBus._off("hashchange", webViewerHashchange); - eventBus._off("beforeprint", _boundEvents.beforePrint); - eventBus._off("afterprint", _boundEvents.afterPrint); - eventBus._off("pagerender", webViewerPageRender); - eventBus._off("pagerendered", webViewerPageRendered); - eventBus._off("updateviewarea", webViewerUpdateViewarea); - eventBus._off("pagechanging", webViewerPageChanging); - eventBus._off("scalechanging", webViewerScaleChanging); - eventBus._off("rotationchanging", webViewerRotationChanging); - eventBus._off("sidebarviewchanged", webViewerSidebarViewChanged); - eventBus._off("pagemode", webViewerPageMode); - eventBus._off("namedaction", webViewerNamedAction); - eventBus._off("presentationmodechanged", webViewerPresentationModeChanged); - eventBus._off("presentationmode", webViewerPresentationMode); - eventBus._off("print", webViewerPrint); - eventBus._off("download", webViewerDownload); - eventBus._off("firstpage", webViewerFirstPage); - eventBus._off("lastpage", webViewerLastPage); - eventBus._off("nextpage", webViewerNextPage); - eventBus._off("previouspage", webViewerPreviousPage); - eventBus._off("zoomin", webViewerZoomIn); - eventBus._off("zoomout", webViewerZoomOut); - eventBus._off("zoomreset", webViewerZoomReset); - eventBus._off("pagenumberchanged", webViewerPageNumberChanged); - eventBus._off("scalechanged", webViewerScaleChanged); - eventBus._off("rotatecw", webViewerRotateCw); - eventBus._off("rotateccw", webViewerRotateCcw); - eventBus._off("optionalcontentconfig", webViewerOptionalContentConfig); - eventBus._off("switchscrollmode", webViewerSwitchScrollMode); - eventBus._off("scrollmodechanged", webViewerScrollModeChanged); - eventBus._off("switchspreadmode", webViewerSwitchSpreadMode); - eventBus._off("spreadmodechanged", webViewerSpreadModeChanged); - eventBus._off("documentproperties", webViewerDocumentProperties); - eventBus._off("findfromurlhash", webViewerFindFromUrlHash); - eventBus._off("updatefindmatchescount", webViewerUpdateFindMatchesCount); - eventBus._off("updatefindcontrolstate", webViewerUpdateFindControlState); - - if (_boundEvents.reportPageStatsPDFBug) { - eventBus._off("pagerendered", _boundEvents.reportPageStatsPDFBug); - eventBus._off("pagechanging", _boundEvents.reportPageStatsPDFBug); - - _boundEvents.reportPageStatsPDFBug = null; - } - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { - eventBus._off("fileinputchange", webViewerFileInputChange); - eventBus._off("openfile", webViewerOpenFile); - } - - _boundEvents.beforePrint = null; - _boundEvents.afterPrint = null; + this._eventBusAbortController?.abort(); + this._eventBusAbortController = null; }, unbindWindowEvents() { diff --git a/web/event_utils.js b/web/event_utils.js index ffc3c858a..29c30166e 100644 --- a/web/event_utils.js +++ b/web/event_utils.js @@ -44,29 +44,21 @@ async function waitOnEventOrTimeout({ target, name, delay = 0 }) { throw new Error("waitOnEventOrTimeout - invalid parameters."); } const { promise, resolve } = Promise.withResolvers(); + const ac = new AbortController(); function handler(type) { - if (target instanceof EventBus) { - target._off(name, eventHandler); - } else { - target.removeEventListener(name, eventHandler); - } + ac.abort(); // Remove event listener. + clearTimeout(timeout); - if (timeout) { - clearTimeout(timeout); - } resolve(type); } - const eventHandler = handler.bind(null, WaitOnType.EVENT); - if (target instanceof EventBus) { - target._on(name, eventHandler); - } else { - target.addEventListener(name, eventHandler); - } + const evtMethod = target instanceof EventBus ? "_on" : "addEventListener"; + target[evtMethod](name, handler.bind(null, WaitOnType.EVENT), { + signal: ac.signal, + }); - const timeoutHandler = handler.bind(null, WaitOnType.TIMEOUT); - const timeout = setTimeout(timeoutHandler, delay); + const timeout = setTimeout(handler.bind(null, WaitOnType.TIMEOUT), delay); return promise; } @@ -87,6 +79,7 @@ class EventBus { this._on(eventName, listener, { external: true, once: options?.once, + signal: options?.signal, }); } @@ -96,10 +89,7 @@ class EventBus { * @param {Object} [options] */ off(eventName, listener, options = null) { - this._off(eventName, listener, { - external: true, - once: options?.once, - }); + this._off(eventName, listener); } /** @@ -138,11 +128,25 @@ class EventBus { * @ignore */ _on(eventName, listener, options = null) { + let rmAbort = null; + if (options?.signal instanceof AbortSignal) { + const { signal } = options; + if (signal.aborted) { + console.error("Cannot use an `aborted` signal."); + return; + } + const onAbort = () => this._off(eventName, listener); + rmAbort = () => signal.removeEventListener("abort", onAbort); + + signal.addEventListener("abort", onAbort); + } + const eventListeners = (this.#listeners[eventName] ||= []); eventListeners.push({ listener, external: options?.external === true, once: options?.once === true, + rmAbort, }); } @@ -155,7 +159,9 @@ class EventBus { return; } for (let i = 0, ii = eventListeners.length; i < ii; i++) { - if (eventListeners[i].listener === listener) { + const evt = eventListeners[i]; + if (evt.listener === listener) { + evt.rmAbort?.(); // Ensure that the `AbortSignal` listener is removed. eventListeners.splice(i, 1); return; } diff --git a/web/pdf_history.js b/web/pdf_history.js index 4ba506ede..5d3baa38d 100644 --- a/web/pdf_history.js +++ b/web/pdf_history.js @@ -53,6 +53,8 @@ function getCurrentHash() { } class PDFHistory { + #eventAbortController = null; + /** * @param {PDFHistoryOptions} options */ @@ -64,7 +66,6 @@ class PDFHistory { this._fingerprint = ""; this.reset(); - this._boundEvents = null; // Ensure that we don't miss a "pagesinit" event, // by registering the listener immediately. this.eventBus._on("pagesinit", () => { @@ -679,29 +680,22 @@ class PDFHistory { } #bindEvents() { - if (this._boundEvents) { + if (this.#eventAbortController) { return; // The event listeners were already added. } - this._boundEvents = { - updateViewarea: this.#updateViewarea.bind(this), - popState: this.#popState.bind(this), - pageHide: this.#pageHide.bind(this), - }; + this.#eventAbortController = new AbortController(); + const { signal } = this.#eventAbortController; - this.eventBus._on("updateviewarea", this._boundEvents.updateViewarea); - window.addEventListener("popstate", this._boundEvents.popState); - window.addEventListener("pagehide", this._boundEvents.pageHide); + this.eventBus._on("updateviewarea", this.#updateViewarea.bind(this), { + signal, + }); + window.addEventListener("popstate", this.#popState.bind(this), { signal }); + window.addEventListener("pagehide", this.#pageHide.bind(this), { signal }); } #unbindEvents() { - if (!this._boundEvents) { - return; // The event listeners were already removed. - } - this.eventBus._off("updateviewarea", this._boundEvents.updateViewarea); - window.removeEventListener("popstate", this._boundEvents.popState); - window.removeEventListener("pagehide", this._boundEvents.pageHide); - - this._boundEvents = null; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; } } diff --git a/web/pdf_scripting_manager.js b/web/pdf_scripting_manager.js index 3dfb7c130..63d08c604 100644 --- a/web/pdf_scripting_manager.js +++ b/web/pdf_scripting_manager.js @@ -37,6 +37,8 @@ class PDFScriptingManager { #docProperties = null; + #eventAbortController = null; + #eventBus = null; #externalServices = null; @@ -108,46 +110,66 @@ class PDFScriptingManager { await this.#destroyScripting(); return; } + const eventBus = this.#eventBus; - this._internalEvents.set("updatefromsandbox", event => { - if (event?.source === window) { - this.#updateFromSandbox(event.detail); - } - }); - this._internalEvents.set("dispatcheventinsandbox", event => { - this.#scripting?.dispatchEventInSandbox(event.detail); - }); + this.#eventAbortController = new AbortController(); + const { signal } = this.#eventAbortController; - this._internalEvents.set("pagechanging", ({ pageNumber, previous }) => { - if (pageNumber === previous) { - return; // The current page didn't change. - } - this.#dispatchPageClose(previous); - this.#dispatchPageOpen(pageNumber); - }); - this._internalEvents.set("pagerendered", ({ pageNumber }) => { - if (!this._pageOpenPending.has(pageNumber)) { - return; // No pending "PageOpen" event for the newly rendered page. - } - if (pageNumber !== this.#pdfViewer.currentPageNumber) { - return; // The newly rendered page is no longer the current one. - } - this.#dispatchPageOpen(pageNumber); - }); - this._internalEvents.set("pagesdestroy", async () => { - await this.#dispatchPageClose(this.#pdfViewer.currentPageNumber); + eventBus._on( + "updatefromsandbox", + event => { + if (event?.source === window) { + this.#updateFromSandbox(event.detail); + } + }, + { signal } + ); + eventBus._on( + "dispatcheventinsandbox", + event => { + this.#scripting?.dispatchEventInSandbox(event.detail); + }, + { signal } + ); - await this.#scripting?.dispatchEventInSandbox({ - id: "doc", - name: "WillClose", - }); + eventBus._on( + "pagechanging", + ({ pageNumber, previous }) => { + if (pageNumber === previous) { + return; // The current page didn't change. + } + this.#dispatchPageClose(previous); + this.#dispatchPageOpen(pageNumber); + }, + { signal } + ); + eventBus._on( + "pagerendered", + ({ pageNumber }) => { + if (!this._pageOpenPending.has(pageNumber)) { + return; // No pending "PageOpen" event for the newly rendered page. + } + if (pageNumber !== this.#pdfViewer.currentPageNumber) { + return; // The newly rendered page is no longer the current one. + } + this.#dispatchPageOpen(pageNumber); + }, + { signal } + ); + eventBus._on( + "pagesdestroy", + async () => { + await this.#dispatchPageClose(this.#pdfViewer.currentPageNumber); - this.#closeCapability?.resolve(); - }); + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "WillClose", + }); - for (const [name, listener] of this._internalEvents) { - this.#eventBus._on(name, listener); - } + this.#closeCapability?.resolve(); + }, + { signal } + ); try { const docProperties = await this.#docProperties(pdfDocument); @@ -168,7 +190,7 @@ class PDFScriptingManager { }, }); - this.#eventBus.dispatch("sandboxcreated", { source: this }); + eventBus.dispatch("sandboxcreated", { source: this }); } catch (error) { console.error(`setDocument: "${error.message}".`); @@ -242,13 +264,6 @@ class PDFScriptingManager { return this.#ready; } - /** - * @private - */ - get _internalEvents() { - return shadow(this, "_internalEvents", new Map()); - } - /** * @private */ @@ -466,10 +481,8 @@ class PDFScriptingManager { this.#willPrintCapability?.reject(new Error("Scripting destroyed.")); this.#willPrintCapability = null; - for (const [name, listener] of this._internalEvents) { - this.#eventBus._off(name, listener); - } - this._internalEvents.clear(); + this.#eventAbortController?.abort(); + this.#eventAbortController = null; this._pageOpenPending.clear(); this._visitedPages.clear(); diff --git a/web/text_highlighter.js b/web/text_highlighter.js index 41721554d..ac682dba1 100644 --- a/web/text_highlighter.js +++ b/web/text_highlighter.js @@ -29,6 +29,8 @@ * either the text layer or XFA layer depending on the type of document. */ class TextHighlighter { + #eventAbortController = null; + /** * @param {TextHighlighterOptions} options */ @@ -37,7 +39,6 @@ class TextHighlighter { this.matches = []; this.eventBus = eventBus; this.pageIdx = pageIndex; - this._onUpdateTextLayerMatches = null; this.textDivs = null; this.textContentItemsStr = null; this.enabled = false; @@ -69,15 +70,18 @@ class TextHighlighter { throw new Error("TextHighlighter is already enabled."); } this.enabled = true; - if (!this._onUpdateTextLayerMatches) { - this._onUpdateTextLayerMatches = evt => { - if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) { - this._updateMatches(); - } - }; + + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this.eventBus._on( "updatetextlayermatches", - this._onUpdateTextLayerMatches + evt => { + if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) { + this._updateMatches(); + } + }, + { signal: this.#eventAbortController.signal } ); } this._updateMatches(); @@ -88,13 +92,10 @@ class TextHighlighter { return; } this.enabled = false; - if (this._onUpdateTextLayerMatches) { - this.eventBus._off( - "updatetextlayermatches", - this._onUpdateTextLayerMatches - ); - this._onUpdateTextLayerMatches = null; - } + + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this._updateMatches(/* reset = */ true); }