├── .gitignore ├── .npmignore ├── src ├── copyright-header.txt └── eventer.js ├── .editorconfig ├── scripts ├── postinstall.js ├── build-gh-pages.js └── build-all.js ├── LICENSE.txt ├── package.json ├── .github └── workflows │ └── build-testbed.yml ├── test ├── index.html └── test.js ├── WEAK.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .gh-build/ 4 | package-lock.json 5 | src/external 6 | test/src 7 | test/dist 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .npmignore 3 | .gitignore 4 | .editorconfig 5 | node_modules/ 6 | .gh-build/ 7 | test/src 8 | test/dist 9 | -------------------------------------------------------------------------------- /src/copyright-header.txt: -------------------------------------------------------------------------------- 1 | /*! byojs/Eventer: #FILENAME# 2 | v#VERSION# (c) #YEAR# Kyle Simpson 3 | MIT License: http://mit-license.org 4 | */ 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | 9 | [*.md] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | 8 | const PKG_ROOT_DIR = path.join(__dirname,".."); 9 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 10 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 11 | 12 | try { fs.symlinkSync(path.join("..","src"),path.join(TEST_DIR,"src"),"dir"); } catch (err) {} 13 | try { fs.symlinkSync(path.join("..","dist"),path.join(TEST_DIR,"dist"),"dir"); } catch (err) {} 14 | try { fs.symlinkSync(path.join("..","dist","external"),path.join(SRC_DIR,"external"),"dir"); } catch (err) {} 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Kyle Simpson 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@byojs/eventer", 3 | "description": "Event emitter with optional async-emit and weak-listener support", 4 | "version": "0.1.2", 5 | "exports": { 6 | "./": "./dist/eventer.mjs" 7 | }, 8 | "browser": { 9 | "@byojs/eventer": "./dist/eventer.mjs" 10 | }, 11 | "scripts": { 12 | "build:all": "node scripts/build-all.js", 13 | "build:gh-pages": "npm run build:all && node scripts/build-gh-pages.js", 14 | "build": "npm run build:all", 15 | "test:start": "npx http-server test/ -p 8080", 16 | "test": "npm run test:start", 17 | "postinstall": "node scripts/postinstall.js", 18 | "prepublishOnly": "npm run build:all" 19 | }, 20 | "dependencies": {}, 21 | "devDependencies": { 22 | "micromatch": "~4.0.8", 23 | "recursive-readdir-sync": "~1.0.6", 24 | "terser": "~5.37.0" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/byojs/eventer.git" 29 | }, 30 | "keywords": [ 31 | "events", 32 | "emitter", 33 | "async", 34 | "pubsub", 35 | "weak references", 36 | "memory" 37 | ], 38 | "bugs": { 39 | "url": "https://github.com/byojs/eventer/issues", 40 | "email": "getify@gmail.com" 41 | }, 42 | "homepage": "https://github.com/byojs/eventer", 43 | "author": "Kyle Simpson ", 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/build-testbed.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build-TestBed 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the "main" branch 8 | push: 9 | branches: [ "main" ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow one concurrent deployment 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: true 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 33 | - uses: actions/checkout@v4 34 | 35 | # Runs a set of commands using the runners shell 36 | - name: install deps and build test bed 37 | run: | 38 | npm install 39 | npm run build:gh-pages 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v5 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload built files 46 | path: './.gh-build' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Eventer: Tests 7 | 8 | 9 | 10 |
11 |

Eventer: Tests

12 | 13 |

Github

14 | 15 |
16 | 17 |

18 | The automated tests run automatically, with results listed below. 19 |

20 |

21 | The "weak listener" tests are designed to be run interactively (likely on a desktop computer); they require you to initiate a GC (garbage collection) between part 1 and part 2, as well as between part 2 and part 3, of the tests. 22 |

23 |

24 | NOTE: Because of the unpredictability of GC, it's possible the browser might automatically perform a GC event between running the part 1 tests and part 2 tests, even if you didn't do so. As such, if you don't trigger the GC explicitly, it's possible that the part 2 or part 2 tests pass sometimes, but don't pass other times. However, if you trigger the GC explicitly, the part 2 and part 3 tests should always pass. 25 |

26 |

27 | To perform a manual GC in your browser: 28 |

29 | 33 | 34 |
35 | 36 |

37 | 38 | 39 |

40 | 41 |
42 | 43 |
44 | 45 |
46 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /scripts/build-gh-pages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | 12 | const PKG_ROOT_DIR = path.join(__dirname,".."); 13 | const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); 14 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 15 | const BUILD_DIR = path.join(PKG_ROOT_DIR,".gh-build"); 16 | const BUILD_DIST_DIR = path.join(BUILD_DIR,"dist"); 17 | 18 | 19 | main().catch(console.error); 20 | 21 | 22 | // ********************** 23 | 24 | async function main() { 25 | console.log("*** Building GH-Pages Deployment ***"); 26 | 27 | // try to make various .gh-build/** directories, if needed 28 | for (let dir of [ BUILD_DIR, BUILD_DIST_DIR, ]) { 29 | if (!(await safeMkdir(dir))) { 30 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 31 | } 32 | } 33 | 34 | // copy test/* files 35 | await copyFilesTo( 36 | recursiveReadDir(TEST_DIR), 37 | TEST_DIR, 38 | BUILD_DIR, 39 | /*skipPatterns=*/[ "**/src", "**/dist", ] 40 | ); 41 | 42 | // patch import reference in test.js to point to dist/ 43 | var testJSPath = path.join(BUILD_DIR,"test.js"); 44 | var testJSContents = await fsp.readFile(testJSPath,{ encoding: "utf8", }); 45 | testJSContents = testJSContents.replace(/(import[^;]+"eventer\/)src([^"]*)"/g,"$1dist$2\""); 46 | await fsp.writeFile(testJSPath,testJSContents,{ encoding: "utf8", }); 47 | 48 | // copy dist/* files 49 | await copyFilesTo( 50 | recursiveReadDir(DIST_DIR), 51 | DIST_DIR, 52 | BUILD_DIST_DIR 53 | ); 54 | 55 | console.log("Complete."); 56 | } 57 | 58 | async function copyFilesTo(files,fromBasePath,toDir,skipPatterns) { 59 | for (let fromPath of files) { 60 | // should we skip copying this file? 61 | if (matchesSkipPattern(fromPath,skipPatterns)) { 62 | continue; 63 | } 64 | 65 | let relativePath = fromPath.slice(fromBasePath.length); 66 | let outputPath = path.join(toDir,relativePath); 67 | let outputDir = path.dirname(outputPath); 68 | 69 | if (!(fs.existsSync(outputDir))) { 70 | if (!(await safeMkdir(outputDir))) { 71 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 72 | } 73 | } 74 | 75 | await fsp.copyFile(fromPath,outputPath); 76 | } 77 | } 78 | 79 | function matchesSkipPattern(pathStr,skipPatterns) { 80 | if (skipPatterns && skipPatterns.length > 0) { 81 | return (micromatch(pathStr,skipPatterns).length > 0); 82 | } 83 | } 84 | 85 | async function safeMkdir(pathStr) { 86 | if (!fs.existsSync(pathStr)) { 87 | try { 88 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 89 | return true; 90 | } 91 | catch (err) {} 92 | return false; 93 | } 94 | return true; 95 | } 96 | -------------------------------------------------------------------------------- /scripts/build-all.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | var terser = require("terser"); 12 | 13 | const PKG_ROOT_DIR = path.join(__dirname,".."); 14 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 15 | const MAIN_COPYRIGHT_HEADER = path.join(SRC_DIR,"copyright-header.txt"); 16 | 17 | const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); 18 | 19 | 20 | main().catch(console.error); 21 | 22 | 23 | // ********************** 24 | 25 | async function main() { 26 | console.log("*** Building JS ***"); 27 | 28 | // try to make various dist/ directories, if needed 29 | for (let dir of [ 30 | DIST_DIR, 31 | ]) { 32 | if (!(await safeMkdir(dir))) { 33 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 34 | } 35 | } 36 | 37 | // read package.json 38 | var packageJSON = require(path.join(PKG_ROOT_DIR,"package.json")); 39 | // read version number from package.json 40 | var version = packageJSON.version; 41 | // read main src copyright-header text 42 | var mainCopyrightHeader = await fsp.readFile(MAIN_COPYRIGHT_HEADER,{ encoding: "utf8", }); 43 | // render main copyright header with version and year 44 | mainCopyrightHeader = ( 45 | mainCopyrightHeader 46 | .replace(/#VERSION#/g,version) 47 | .replace(/#YEAR#/g,(new Date()).getFullYear()) 48 | ); 49 | 50 | // build src/* to dist/ 51 | await buildFiles( 52 | recursiveReadDir(SRC_DIR), 53 | SRC_DIR, 54 | DIST_DIR, 55 | (contents,outputPath,filename = path.basename(outputPath)) => prepareFileContents( 56 | contents, 57 | outputPath.replace(/\.js$/,".mjs"), 58 | filename.replace(/\.js$/,".mjs") 59 | ), 60 | /*skipPatterns=*/[ "**/*.txt", "**/*.json", "**/external" ] 61 | ); 62 | 63 | console.log("Complete."); 64 | 65 | 66 | // **************************** 67 | 68 | async function prepareFileContents(contents,outputPath,filename = path.basename(outputPath)) { 69 | // JS file (to minify)? 70 | if (/\.[mc]?js$/i.test(filename)) { 71 | contents = await minifyJS(contents); 72 | } 73 | 74 | // add copyright header 75 | return { 76 | contents: `${ 77 | mainCopyrightHeader.replace(/#FILENAME#/g,filename) 78 | }\n${ 79 | contents 80 | }`, 81 | 82 | outputPath, 83 | }; 84 | } 85 | } 86 | 87 | async function buildFiles(files,fromBasePath,toDir,processFileContents,skipPatterns) { 88 | for (let fromPath of files) { 89 | // should we skip copying this file? 90 | if (matchesSkipPattern(fromPath,skipPatterns)) { 91 | continue; 92 | } 93 | let relativePath = fromPath.slice(fromBasePath.length); 94 | let outputPath = path.join(toDir,relativePath); 95 | let contents = await fsp.readFile(fromPath,{ encoding: "utf8", }); 96 | ({ contents, outputPath, } = await processFileContents(contents,outputPath)); 97 | let outputDir = path.dirname(outputPath); 98 | 99 | if (!(fs.existsSync(outputDir))) { 100 | if (!(await safeMkdir(outputDir))) { 101 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 102 | } 103 | } 104 | 105 | await fsp.writeFile(outputPath,contents,{ encoding: "utf8", }); 106 | } 107 | } 108 | 109 | async function minifyJS(contents,esModuleFormat = true) { 110 | let result = await terser.minify(contents,{ 111 | mangle: { 112 | keep_fnames: true, 113 | }, 114 | compress: { 115 | keep_fnames: true, 116 | }, 117 | output: { 118 | comments: /^!/, 119 | }, 120 | module: esModuleFormat, 121 | }); 122 | if (!(result && result.code)) { 123 | if (result.error) throw result.error; 124 | else throw result; 125 | } 126 | return result.code; 127 | } 128 | 129 | function matchesSkipPattern(pathStr,skipPatterns) { 130 | if (skipPatterns && skipPatterns.length > 0) { 131 | return (micromatch(pathStr,skipPatterns).length > 0); 132 | } 133 | } 134 | 135 | async function safeMkdir(pathStr) { 136 | if (!fs.existsSync(pathStr)) { 137 | try { 138 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 139 | return true; 140 | } 141 | catch (err) {} 142 | return false; 143 | } 144 | return true; 145 | } 146 | -------------------------------------------------------------------------------- /WEAK.md: -------------------------------------------------------------------------------- 1 | # Weak Event Listeners 2 | 3 | *Weak event listeners* is a pattern for managing the subscription of events, where the emitter holds a reference to the listener (function) *weakly*. This is a powerful capability, but it requires much more careful attention from the developer to make sure it's being used appropriately. 4 | 5 | JS only recently (in the last few years) gained the ability to properly support *weak event listeners*, which is likely the primary reason that currently, almost no other event emitter implementations besides **Eventer** support this. This useful (but advanced!) capability will probably gain more traction going forward. 6 | 7 | ## Background: Garbage 8 | 9 | Garbage Collection (GC) is a background process that the JS engine applies, to free up memory it previously allocated (for values, variables, function scopes, etc), once those elements are no longer *in scope* (aka *reachable*). 10 | 11 | For example, creating a large array (thousands of elements or more) might take up a non-trivial amount of memory (hundreds or thousands of KB, or even MB). Once you're done using that array, your app *should* clean that up so the JS engine to reclaim that memory. 12 | 13 | **Tip:** Even if you don't care that much, the users of your application might! Memory waste contributes to slower applications, faster battery drain, etc. 14 | 15 | If there's a large value (object, array, function closure, etc), and four different references to that value have been set (in variables, object properties, function parameters, etc), that value will stay in memory until **all four** references are cleaned up. Once they are, the large value itself is now *unreachable*, and the JS engine knows it's now safe to free up that memory. 16 | 17 | From the JS code perspective, all you need to do to *cleanup* is to unset that large value by setting its variable/container to another value, typically `null` or `undefined`. That's actually all you *can* do. The JS engine's GC does the rest. But it does so in the background, based on a variety of complicated decision and timing factors that are completely *opaque* to us JS developers. 18 | 19 | **Note:** Though you cannot programmatically control GC -- only *influence* it -- from your JS program, various browsers do provide developers non-programmatic access to trigger GC (for debugging/analysis purposes). For example, in Chrome (desktop) devtools, there's a "Memory" tab, and on there a button called "Collect Garbage". In Firefox (desktop), there's an `about:memory` page you open a tab to, with a "GC" button. 20 | 21 | ### Memory "Leaks" 22 | 23 | The classic definition of a "memory leak" means memory that can never be reclaimed. In other words, memory that was allocated in some way, but the handle to that memory has been discarded, and now that memory that can't be de-allocated; the only "solution" is to restart a process (e.g., browser, tab), or even the whole device. 24 | 25 | With modern, well-behaving JS engines, true JS program "memory leaks" -- in that classic sense, anyway -- are exceedingly rare. However, JS programs can absolutely *accrete* memory (not technically *leaking*) throughout their lifetime, where they are accidentally holding onto memory they're no longer using, and the GC isn't able to cleanup for us. This leads to **GC prevention**. 26 | 27 | The most classic example of this is when a large value (array, object) is referenced/used in a function, and that function is registered as an event listener. Even if the program never references that value to use it again, the value is nonetheless kept around, because the JS engine has to assume that possibly, that event might fire to call the listener, where it'd be expecting that value to still be there. This is called "accidental closure". 28 | 29 | Even if the program intentionally unsets all its own references to that function (closure), an event emitter would typically hold a *strong* reference to that listener function, and thus prevent its GC (and the GC of the large array/object value). 30 | 31 | Explicitly unregistering a no-longer-used event listener is the easiest way to avoid this particular type of GC prevention. 32 | 33 | But this is typically challenging in complex apps, to keep track of the appropriate lifetimes of all events. 34 | 35 | ### Precedence 36 | 37 | A quick web search will confirm that "weak event listeners" is not a new idea, or only related to JS. Many other languages/systems have such capabilities, and have relied on them for a long time. 38 | 39 | JS is still essentially *brand new* to this trend. 40 | 41 | ### JS Weakness 42 | 43 | JS has historically not supported *weak references*, which meant it was actually impossible to implement a *weak event listener* emitter. 44 | 45 | **Note:** There was good reason for JS to resist adding such features. There was (and still is!) concern that exposing the activity of a GC (background process) into the observable behavior of a JS program, could create potential security/privacy vulnerabilities, as well as lead to harder to understand/debug JS programs. Moreover, these features make it harder for JS engines to perform some types of optimizations. 46 | 47 | Back in ES6 (2015), JS added `WeakMap` (and `WeakSet`), but these only provide part of the solution. A `WeakMap` only holds its *key* weakly, but its value strongly; the reverse is actually what's needed for a *weak event listener* system. 48 | 49 | `WeakSet` holds its values weakly (good!), but is not enumerable (bad!). Without enumerability, an event emitter isn't able to get a list of all listeners to fire when you emit an event. 50 | 51 | Only in the last couple of years did JS finally address the *design weakness* in this respect, by finally providing [`WeakRef`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef) and [`FinalizationRegistry`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry). Now, *weak event listener* implementations are fully possible. 52 | 53 | But it's a very nascent area of capability for JS, given the feature newness. Most JS developers either don't know this is possible, or don't even understand what it's for to begin with! 54 | 55 | ## Weakly-held listeners 56 | 57 | By weakly holding event listeners, the GC prevention (by "accidental closure") problem discussed above is more likely avoided. The emitter instance **DOES NOT** prevent the listener function -- and particularly, anything the function has a closure over! -- from being cleaned up by GC (garbage collection). 58 | 59 | That means, if you forget to unsubscribe an event emitter, but you properly clean up (or don't hold in the first place!) any references to its listener, the emitter won't prevent the GC of that listener. Once the JS engine GC cleans up those listeners (and closures!), the event emitter will basically "detect" this and implicitly remove its internal subscriptions automatically. 60 | 61 | Usage of a *weak event listener* emitter gives you much finer control over the memory allocation behavior. This capability is a big win, if understood by JS developers, and properly and safely used in their programs. 62 | 63 | ## Weak Weaknesses 64 | 65 | As a wise grandpa once said: 66 | 67 | > With great power comes great responsibility. 68 | 69 | The downside (err... *weakness*) of a *weak event listener* emitter is that it's possible, depending on the habits of how you use it, to create very unpredictable behavior (and maddening program bugs!). 70 | 71 | [As described here](README.md#accidental-unsubscription), if you aren't careful to keep any other references to a listener -- for example, passing only an inline function (e.g., `=>` arrow function) -- the JS engine's GC *will (eventually) do its job*, and clean up those functions/closures (and unsubscribe the events in **Eventer**). 72 | 73 | ```js 74 | function listenToWhatever() { 75 | events.on( 76 | "whatever", 77 | () => console.log("'whatever' event fired!") 78 | ); 79 | } 80 | 81 | listenToWhatever(); 82 | // "whatever" events might fire after here for awhile, 83 | // then all of a sudden stop firing (because of GC)! 84 | ``` 85 | 86 | That means you might be observing event handlers firing the way you want, and suddently they'd stop firing, even though nothing else in the program changed. That would happen unpredictably, as the background GC process runs. 87 | 88 | Hopefully, it's clear just how *dangerous* it is to have unpredictable program behavior like that! 89 | 90 | ## Solution 91 | 92 | The only plausible solution here, while still taking advantage of *weak event listeners* capabiliity when it's actually helpful, is to ensure you only ever pass event listener functions that are stably and predictably referenced elsehwere in the program. 93 | 94 | In practice, this basically means, **never pass inline listener functions** to a *weak event listener* emitter. Moreover, be careful even with inner function declarations, if the enclosing scope might go away via GC. 95 | 96 | Always store references to functions used as a event listeners in objects (or classes) that survive beyond single function scopes, or even directly in module/global scope, so the listeners never *accidentally* go away. 97 | 98 | Of course, if you can, you should *always* explicitly unsubscribe events. But if for some reason you can't or don't, a weak-event-listener emitter will clean up your mess for you! 99 | -------------------------------------------------------------------------------- /src/eventer.js: -------------------------------------------------------------------------------- 1 | // Parts of this implementation adapted from: 2 | // https://stackoverflow.com/a/78908317 3 | // https://github.com/tc39/proposal-weakrefs/blob/ 4 | // a13c3efc5d3b547e05731fa2af7e50348cf61173/README.md#iterable-listenerMaps 5 | 6 | var finalization = new FinalizationRegistry( 7 | ({ refs, ref, signalRefs, }) => { 8 | removeFromList(refs,ref); 9 | if (signalRefs != null) { 10 | for ( 11 | let { signalRef, onAbortSignalRef, } of 12 | Object.values(signalRefs) 13 | ) { 14 | // note: these may very well have already been 15 | // GC'd, so there may be nothing to do here 16 | let signal = signalRef.deref(); 17 | let onAbortSignal = onAbortSignalRef.deref(); 18 | if (signal != null && onAbortSignal != null) { 19 | signal.removeEventListener("abort",onAbortSignal); 20 | } 21 | } 22 | } 23 | } 24 | ); 25 | var Eventer = defineEventerClass(); 26 | 27 | 28 | // *********************** 29 | 30 | export { Eventer, }; 31 | export default Eventer; 32 | 33 | 34 | // *********************** 35 | 36 | // note: this function "hoists" the class 37 | // definition above the `export`s, for 38 | // desired file layout-order without TDZ, 39 | // and also wraps it in a proxy to provide 40 | // both a `new` constructor form and a 41 | // regular factory function form 42 | function defineEventerClass() { 43 | class Eventer { 44 | #listenerEntries = new WeakMap(); 45 | #listenerRefsByEvent = {}; 46 | #listenerSet; 47 | #asyncEmit; 48 | 49 | constructor({ 50 | weakListeners = true, 51 | asyncEmit = false, 52 | } = {}) { 53 | this.#listenerSet = (!weakListeners ? new Set() : null); 54 | this.#asyncEmit = asyncEmit; 55 | } 56 | 57 | // note: only usable in `weakListeners:false` mode 58 | releaseListeners(listener) { 59 | if (listener != null) { 60 | this.#listenerSet?.delete(listener); 61 | } 62 | else { 63 | this.#listenerSet?.clear(); 64 | } 65 | } 66 | 67 | on(eventName,listener,{ signal, } = {}) { 68 | // already-aborted AbortSignal passed in? 69 | if (signal != null && signal.aborted) return false; 70 | 71 | // if not in "weak-listeners" mode, store a 72 | // reference to prevent GC 73 | this.#listenerSet?.add(listener); 74 | 75 | // retrieve (weakly linked) listener entry (if any) 76 | var listenerEntry = this.#listenerEntries.get(listener); 77 | 78 | // first time registering this listener? 79 | if (listenerEntry == null) { 80 | // (weakly) hold reference to listener (to allow 81 | // GC of listener by host program) 82 | let listenerRef = new WeakRef(listener); 83 | 84 | // (strongly) link listener-weak-ref to event 85 | this.#listenerRefsByEvent[eventName] = ( 86 | this.#listenerRefsByEvent[eventName] ?? [] 87 | ); 88 | this.#listenerRefsByEvent[eventName].push(listenerRef); 89 | 90 | listenerEntry = { 91 | // register event on listener entry 92 | events: [ eventName, ], 93 | onceEvents: [], 94 | ref: listenerRef, 95 | }; 96 | 97 | // (weakly) link listener to its entry 98 | this.#listenerEntries.set(listener,listenerEntry); 99 | 100 | // AbortSignal passed in? 101 | if (signal != null) { 102 | // weakly hold reference to signal, to 103 | // remove its event listener later 104 | let signalRef = new WeakRef(signal); 105 | 106 | // handler for when signal is aborted 107 | let onAbortSignal = () => { 108 | // weak reference still points at a 109 | // signal? 110 | var theSignal = signalRef.deref(); 111 | var theHandler = onAbortSignalRef.deref(); 112 | if (theSignal != null && theHandler != null) { 113 | theSignal.removeEventListener("abort",theHandler); 114 | } 115 | 116 | // weak reference still points at a 117 | // listener? 118 | var listener = listenerRef.deref(); 119 | if (listener != null) { 120 | this.off(eventName,listener); 121 | } 122 | }; 123 | let onAbortSignalRef = new WeakRef(onAbortSignal); 124 | 125 | signal.addEventListener("abort",onAbortSignal); 126 | 127 | // save signal/handler weak references for later 128 | // unsubscription, upon GC of listener 129 | listenerEntry.signalRefs = { 130 | [eventName]: { 131 | signalRef, 132 | onAbortSignalRef, 133 | }, 134 | }; 135 | } 136 | 137 | // listen for GC of listener, to unregister any 138 | // event subscriptions (clean up memory) 139 | finalization.register( 140 | listener, 141 | { 142 | refs: this.#listenerRefsByEvent[eventName], 143 | ref: listenerRef, 144 | signalRefs: listenerEntry.signalRefs, 145 | }, 146 | listenerRef 147 | ); 148 | 149 | return true; 150 | } 151 | // listener entry does NOT have this event registered? 152 | else if (!listenerEntry.events.includes(eventName)) { 153 | let listenerRef = listenerEntry.ref; 154 | 155 | // register event on listener entry 156 | listenerEntry.events.push(eventName); 157 | 158 | // (strongly) link listener-weak-ref to event 159 | this.#listenerRefsByEvent[eventName] = ( 160 | this.#listenerRefsByEvent[eventName] ?? [] 161 | ); 162 | this.#listenerRefsByEvent[eventName].push(listenerRef); 163 | 164 | // AbortSignal passed in? 165 | if (signal != null) { 166 | // weakly hold reference to signal, to 167 | // remove its event listener later 168 | let signalRef = new WeakRef(signal); 169 | 170 | // handler for when signal is aborted 171 | let onAbortSignal = () => { 172 | // weak reference still points at a 173 | // signal? 174 | var theSignal = signalRef.deref(); 175 | var theHandler = onAbortSignalRef.deref(); 176 | if (theSignal != null && theHandler != null) { 177 | theSignal.removeEventListener("abort",theHandler); 178 | } 179 | 180 | // weak reference still points at a 181 | // listener? 182 | var listener = listenerRef.deref(); 183 | if (listener != null) { 184 | this.off(eventName,listener); 185 | } 186 | }; 187 | let onAbortSignalRef = new WeakRef(onAbortSignal); 188 | 189 | signal.addEventListener("abort",onAbortSignal); 190 | 191 | // save signal/handler weak references for later 192 | // unsubscription, upon GC of listener 193 | listenerEntry.signalRefs = listenerEntry.signalRefs ?? {}; 194 | listenerEntry.signalRefs[eventName] = { 195 | signalRef, 196 | onAbortSignalRef, 197 | }; 198 | } 199 | 200 | return true; 201 | } 202 | 203 | return false; 204 | } 205 | 206 | once(eventName,listener,opts) { 207 | if (this.on(eventName,listener,opts)) { 208 | // (weakly) remember that this is a "once" 209 | // registration (to unregister after first 210 | // `emit()`) 211 | this.#listenerEntries.get(listener) 212 | .onceEvents.push(eventName); 213 | 214 | return true; 215 | } 216 | 217 | return false; 218 | } 219 | 220 | off(eventName,listener) { 221 | var listenerRecords = ( 222 | ( 223 | // unsubscribe all listeners? 224 | listener == null ? 225 | // get all listener-weak-refs for event 226 | // (or all of them, if no event specified) 227 | this.#getListenerRefs(eventName) : 228 | 229 | // otherwise, unsubscribe specific listener 230 | ( 231 | ( 232 | // listener has been registered? 233 | this.#listenerEntries.has(listener) && 234 | 235 | ( 236 | // unregistering all events? 237 | eventName == null || 238 | 239 | // or specific event registered? 240 | this.#listenerEntries.get(listener) 241 | .events.includes(eventName) 242 | ) 243 | ) ? 244 | [ this.#listenerEntries.get(listener).ref ] : 245 | 246 | // nothing to do (no listener+event) 247 | [] 248 | ) 249 | ) 250 | .map(ref => [ ref.deref(), ref, ]) 251 | .filter(([ listenerFn, ]) => !!listenerFn) 252 | .map(([ listenerFn, listenerRef, ]) => [ 253 | listenerFn, 254 | listenerRef, 255 | this.#listenerEntries.get(listenerFn), 256 | ]) 257 | ); 258 | 259 | // any listeners to unsubscribe? 260 | if (listenerRecords.length > 0) { 261 | // process unsubscription(s) 262 | for (let [ listenerFn, listenerRef, listenerEntry, ] of 263 | listenerRecords 264 | ) { 265 | if (eventName != null) { 266 | // unlink event from listener entry 267 | removeFromList(listenerEntry.events,eventName); 268 | removeFromList(listenerEntry.onceEvents,eventName); 269 | 270 | // unlink listener-weak-ref from event 271 | removeFromList( 272 | this.#listenerRefsByEvent[eventName], 273 | listenerRef 274 | ); 275 | 276 | // all listener-weak-refs now unlinked from event? 277 | if (this.#listenerRefsByEvent[eventName].length == 0) { 278 | this.#listenerRefsByEvent[eventName] = null; 279 | } 280 | 281 | // abort signal (for event) to clean up? 282 | if (listenerEntry.signalRefs?.[eventName] != null) { 283 | let signal = listenerEntry.signalRefs[eventName].signalRef.deref(); 284 | let onAbortSignal = listenerEntry.signalRefs[eventName].onAbortSignalRef.deref(); 285 | if (signal != null && onAbortSignal != null) { 286 | signal.removeEventListener("abort",onAbortSignal); 287 | } 288 | delete listenerEntry.signalRefs[eventName]; 289 | } 290 | } 291 | else { 292 | // note: will trigger (below) deleting the 293 | // whole entry, which is why we don't need 294 | // to empty `onceEvents` list 295 | listenerEntry.events.length = 0; 296 | 297 | for (let [ evt, refList, ] of 298 | Object.entries(this.#listenerRefsByEvent) 299 | ) { 300 | removeFromList(refList,listenerRef); 301 | 302 | // all listener-weak-refs now removed from 303 | // this event? 304 | if (refList?.length == 0) { 305 | this.#listenerRefsByEvent[evt] = null; 306 | } 307 | } 308 | 309 | // abort signal(s) to cleanup? 310 | if (listenerEntry.signalRefs != null) { 311 | for ( 312 | let { signalRef, onAbortSignalRef, } of 313 | Object.values(listenerEntry.signalRefs) 314 | ) { 315 | let signal = signalRef.deref(); 316 | let onAbortSignal = onAbortSignalRef.deref(); 317 | if (signal != null && onAbortSignal != null) { 318 | signal.removeEventListener("abort",onAbortSignal); 319 | } 320 | } 321 | delete listenerEntry.signalRefs; 322 | } 323 | } 324 | 325 | // all events now unlinked from listener entry? 326 | if (listenerEntry.events.length == 0) { 327 | // release any GC-protection of listener 328 | this.releaseListeners(listenerFn); 329 | 330 | // delete the whole entry 331 | this.#listenerEntries.delete(listenerFn); 332 | 333 | // stop listening for GC 334 | finalization.unregister(listenerRef); 335 | } 336 | } 337 | 338 | return true; 339 | } 340 | 341 | return false; 342 | } 343 | 344 | emit(eventName,...args) { 345 | var listeners = ( 346 | (this.#listenerRefsByEvent[eventName] || []) 347 | .map(ref => ref.deref()) 348 | .filter(Boolean) 349 | ); 350 | if (listeners.length > 0) { 351 | let onceEventUnsubscribers = new Map( 352 | listeners 353 | // were any listeners of this event of the 354 | // "once" type? 355 | .filter(listener => ( 356 | // was this registered as a "once" listener? 357 | this.#listenerEntries.get(listener) 358 | .onceEvents.includes(eventName) 359 | )) 360 | 361 | // produce list of entries ([key,value] tuples) 362 | // to popuplate `onceEventUnsubscribers` Map 363 | .map(onceListener => [ 364 | onceListener, 365 | () => this.off(eventName,onceListener), 366 | ]) 367 | ); 368 | let triggerEvents = () => { 369 | for (let listener of listeners) { 370 | // was this listener a "once" listener? 371 | if (onceEventUnsubscribers.has(listener)) { 372 | // run the unsubscriber 373 | onceEventUnsubscribers.get(listener)(); 374 | onceEventUnsubscribers.delete(listener); 375 | } 376 | 377 | try { 378 | listener.apply(this,args); 379 | } 380 | catch (err) { 381 | console.error(err); 382 | } 383 | } 384 | listeners = onceEventUnsubscribers = triggerEvents = null; 385 | }; 386 | 387 | // in async-emit mode? 388 | if (this.#asyncEmit) { 389 | // trigger event on next async microtask 390 | Promise.resolve().then(triggerEvents); 391 | 392 | // process unsubscribes immediately 393 | for (let unsubscribe of onceEventUnsubscribers.values()) { 394 | unsubscribe(); 395 | } 396 | onceEventUnsubscribers.clear(); 397 | } 398 | else { 399 | triggerEvents(); 400 | } 401 | 402 | return true; 403 | } 404 | 405 | return false; 406 | } 407 | 408 | #getListenerRefs(eventName) { 409 | return ( 410 | eventName == null ? ( 411 | // flattened list of all registered 412 | // listener-weak-refs 413 | Object.values(this.#listenerRefsByEvent) 414 | .flatMap(refs => refs ?? []) 415 | ) : 416 | 417 | // list of all event's listener-weak-refs (if any) 418 | (this.#listenerRefsByEvent[eventName] ?? []) 419 | ); 420 | } 421 | }; 422 | 423 | // proxy to let `Eventer()` be both a constructor (via 424 | // `new`) and a regular factory function 425 | return new Proxy(Eventer,{ 426 | construct(target,args,receiver) { 427 | return Reflect.construct(target,args,receiver); 428 | }, 429 | apply(target,thisArg,args) { 430 | return Reflect.construct(target,args); 431 | }, 432 | getPrototypeOf(target) { 433 | return Reflect.getPrototypeOf(target); 434 | }, 435 | setPrototypeOf() { 436 | return true; 437 | }, 438 | get(target,prop,receiver) { 439 | return Reflect.get(target,prop,receiver); 440 | }, 441 | set(obj,prop,value) { 442 | return Reflect.set(obj,prop,value); 443 | }, 444 | }); 445 | } 446 | 447 | function removeFromList(list,val) { 448 | var idx = list?.indexOf(val); 449 | if (~(idx ?? -1)) { 450 | list.splice(idx,1); 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | // note: this module specifier comes from the import-map 2 | // in index.html; swap "src" for "dist" here to test 3 | // against the dist/* files 4 | import Eventer from "eventer/src"; 5 | 6 | 7 | // *********************** 8 | 9 | var testResultsEl; 10 | var weakTests = {}; 11 | 12 | if (document.readyState == "loading") { 13 | document.addEventListener("DOMContentLoaded",ready,false); 14 | } 15 | else { 16 | ready(); 17 | } 18 | 19 | 20 | // *********************** 21 | 22 | async function ready() { 23 | var runAutomatedTestsBtn = document.getElementById("run-automated-tests-btn"); 24 | var runWeakTestsPart1Btn = document.getElementById("run-weak-tests-part-1-btn"); 25 | testResultsEl = document.getElementById("test-results"); 26 | 27 | runAutomatedTestsBtn.addEventListener("click",runAutomatedTests); 28 | runWeakTestsPart1Btn.addEventListener("click",runWeakTestsPart1); 29 | 30 | try { 31 | await runAutomatedTests(); 32 | } 33 | catch (err) { 34 | logError(err); 35 | testResultsEl.innerHTML = "(Automated Tests) FAILED -- see console"; 36 | } 37 | 38 | runWeakTestsPart1Btn.disabled = false; 39 | } 40 | 41 | async function runAutomatedTests() { 42 | cleanupWeakTestsButtons(); 43 | 44 | testResultsEl.innerHTML = "Running automated tests...
"; 45 | 46 | for (let testFn of [ runSyncTests, runAsyncTests, ]) { 47 | let result = await testFn(); 48 | testResultsEl.innerHTML += "
"; 49 | if (!result) { 50 | return; 51 | } 52 | } 53 | 54 | testResultsEl.innerHTML += "(All automated tests) PASSED.
"; 55 | } 56 | 57 | async function runSyncTests() { 58 | var results = []; 59 | var expected = [ 60 | true, 61 | false, 62 | true, 63 | false, 64 | "A: 0 (true)", 65 | "B: 0", 66 | true, 67 | "A: 1 (true)", 68 | true, 69 | "A: 2 (true)", 70 | true, 71 | false, 72 | "A: 4 (true)", 73 | true, 74 | true, 75 | false, 76 | false, 77 | true, 78 | true, 79 | false, 80 | true, 81 | true, 82 | "C: 7", 83 | "D: 8", 84 | "D: -9", 85 | true, 86 | true, 87 | true, 88 | true, 89 | true, 90 | true, 91 | false, 92 | true, 93 | false, 94 | false, 95 | false, 96 | false, 97 | false, 98 | true, 99 | "A: 16 (true)", 100 | true, 101 | false, 102 | true, 103 | "A2: 18 (true)", 104 | true, 105 | false, 106 | "MyEventer.on", 107 | true, 108 | "MyEventer.customEmit", 109 | "A3: 19 (true)", 110 | true, 111 | false, 112 | true, 113 | "A: 20 (true)", 114 | true, 115 | false, 116 | false, 117 | false, 118 | true, 119 | false, 120 | false, 121 | true, 122 | true, 123 | true, 124 | "A: 23 (true)", 125 | true, 126 | "A: 24 (true)", 127 | true, 128 | "A: 25 (true)", 129 | true, 130 | false, 131 | false, 132 | "A: 28 (true)", 133 | true, 134 | false, 135 | false, 136 | false, 137 | false, 138 | ]; 139 | 140 | class MyEventer extends Eventer { 141 | on(...args) { 142 | results.push("MyEventer.on"); 143 | return super.on(...args); 144 | } 145 | customEmit(...args) { 146 | results.push("MyEventer.customEmit"); 147 | return this.emit(...args); 148 | } 149 | } 150 | 151 | try { 152 | // NOTE: `var`s intentional here, for hoisting 153 | // outside the `try` block 154 | var events = new Eventer({ asyncEmit: false, weakListeners: false, }); 155 | var events2 = Eventer({ asyncEmit: false, weakListeners: false, }); 156 | var events3 = new MyEventer({ asyncEmit: false, weakListeners: false, }); 157 | var counter = 0; 158 | var symbolEvent = Symbol("symbol event"); 159 | var emitFn = events.emit; 160 | var onFnBound = events.on.bind(events); 161 | var AC1 = new AbortController(); 162 | var AC2 = new AbortController(); 163 | var AC3 = new AbortController(); 164 | var AC4 = new AbortController(); 165 | var AS1 = AC1.signal; 166 | var AS2 = AC2.signal; 167 | var AS3 = AC3.signal; 168 | var AS4 = AC4.signal; 169 | 170 | results.push( onFnBound("test",A) ); 171 | results.push( onFnBound("test",A) ); 172 | results.push( events.once("test",B) ); 173 | results.push( events.once("test",B) ); 174 | results.push( emitFn.call(events,"test",counter++) ); 175 | results.push( emitFn.call(events,"test",counter++) ); 176 | results.push( events.emit("test",counter++) ); 177 | results.push( events.emit("test-2",counter++) ); 178 | results.push( events.emit("test",counter++) ); 179 | results.push( events.off("test",A) ); 180 | results.push( events.off("test",A) ); 181 | results.push( events.emit("test",counter++) ); 182 | results.push( events.on("test",A) ); 183 | results.push( events.off("test",A) ); 184 | results.push( events.emit("test",counter++) ); 185 | results.push( events.on("test",C) ); 186 | results.push( events.on("test-2",D) ); 187 | results.push( events.emit("test",counter++) ); 188 | results.push( events.off("test",C) ); 189 | results.push( events.off("test-2",D) ); 190 | results.push( events.once("test-2",D) ); 191 | results.push( events.emit("test",counter++) ); 192 | results.push( events.off("test-2",D) ); 193 | events.on("test",A); 194 | events.on("test",B); 195 | events.off("test"); 196 | results.push( events.emit("test",counter++) ); 197 | events.on("test",A); 198 | events.on("test-2",A); 199 | events.off(null,A); 200 | results.push( events.emit("test",counter++) ); 201 | results.push( events.emit("test-2",counter++) ); 202 | events.on("test",A); 203 | events.on("test-2",A); 204 | events.on("test",B); 205 | events.on("test-2",B); 206 | events.off(); 207 | results.push( events.emit("test",counter++) ); 208 | results.push( events.emit("test-2",counter++) ); 209 | results.push( events.once(symbolEvent,A) ); 210 | results.push( events.emit(symbolEvent,counter++) ); 211 | results.push( events.emit(symbolEvent,counter++) ); 212 | results.push( events2.once("test",A2) ); 213 | results.push( events2.emit("test",counter++) ); 214 | results.push( events2.off("test",A2) ); 215 | results.push( events3.once("test",A3) ); 216 | results.push( events3.customEmit("test",counter++) ); 217 | results.push( events3.off("test",A3) ); 218 | 219 | results.push( events.on("test",A,{ signal: AS1, }) ); 220 | results.push( events.emit("test",counter++) ); 221 | AC1.abort("unsubscribe-1"); 222 | results.push( events.emit("test",counter++) ); 223 | results.push( events.off("test",A) ); 224 | results.push( events.on("test",A,{ signal: AS1, }) ); 225 | results.push( events.once("test",A,{ signal: AS2, }) ); 226 | AC2.abort("unsubscribe-2"); 227 | results.push( events.emit("test",counter++) ); 228 | results.push( events.off("test",A) ); 229 | 230 | results.push( events.on("test-2",A,{ signal: AS3, }) ); 231 | results.push( events.on("test-3",A,{ signal: AS3, }) ); 232 | results.push( events.on("test-4",A,{ signal: AS4, }) ); 233 | results.push( events.emit("test-2",counter++) ); 234 | results.push( events.emit("test-3",counter++) ); 235 | results.push( events.emit("test-4",counter++) ); 236 | AC3.abort("unsubscribe-3"); 237 | results.push( events.emit("test-2",counter++) ); 238 | results.push( events.emit("test-3",counter++) ); 239 | results.push( events.emit("test-4",counter++) ); 240 | results.push( events.off("test-2",A) ); 241 | results.push( events.off("test-3",A) ); 242 | AC4.abort("unsubscribe-4"); 243 | results.push( events.emit("test-4",counter++) ); 244 | results.push( events.off("test-4",A) ); 245 | 246 | if (JSON.stringify(results) == JSON.stringify(expected)) { 247 | testResultsEl.innerHTML += "(Sync Tests) PASSED."; 248 | return true; 249 | } 250 | else { 251 | testResultsEl.innerHTML += "(Sync Tests) FAILED.

"; 252 | reportExpectedActual(expected,results); 253 | } 254 | } 255 | catch (err) { 256 | logError(err); 257 | testResultsEl.innerHTML += "(Sync Tests) FAILED -- see console."; 258 | } 259 | return false; 260 | 261 | 262 | // *********************** 263 | 264 | function A(msg) { 265 | results.push(`A: ${msg} (${this === events})`); 266 | } 267 | 268 | function A2(msg) { 269 | results.push(`A2: ${msg} (${this === events2})`); 270 | } 271 | 272 | function A3(msg) { 273 | results.push(`A3: ${msg} (${this === events3})`); 274 | } 275 | 276 | function B(msg) { 277 | results.push(`B: ${msg}`); 278 | } 279 | 280 | function C(msg) { 281 | results.push(`C: ${msg}`); 282 | results.push( events.emit("test-2",counter++) ); 283 | } 284 | 285 | function D(msg) { 286 | results.push(`D: ${msg}`); 287 | if (msg > 0) { 288 | results.push( events.emit("test-2",-1 * (counter++)) ); 289 | } 290 | } 291 | } 292 | 293 | async function runAsyncTests() { 294 | var results = []; 295 | var expected = [ 296 | true, 297 | false, 298 | true, 299 | false, 300 | true, 301 | true, 302 | true, 303 | false, 304 | "A: 0", 305 | "B: 0", 306 | "A: 1", 307 | "A: 2", 308 | true, 309 | "A: 4", 310 | true, 311 | false, 312 | false, 313 | true, 314 | true, 315 | false, 316 | true, 317 | true, 318 | true, 319 | "A: 7", 320 | true, 321 | true, 322 | true, 323 | "C: 8", 324 | true, 325 | "D: 9", 326 | true, 327 | "D: -10", 328 | true, 329 | true, 330 | true, 331 | true, 332 | "C: 11", 333 | true, 334 | "D: 12", 335 | false, 336 | false, 337 | ]; 338 | 339 | try { 340 | // NOTE: `var`s intentional here, for hoisting 341 | // outside the `try` block 342 | var events = new Eventer({ asyncEmit: true, weakListeners: false, }); 343 | var counter = 0; 344 | 345 | results.push( events.on("test",A) ); 346 | results.push( events.on("test",A) ); 347 | results.push( events.once("test",B) ); 348 | results.push( events.once("test",B) ); 349 | results.push( events.emit("test",counter++) ); 350 | results.push( events.emit("test",counter++) ); 351 | results.push( events.emit("test",counter++) ); 352 | results.push( events.emit("test-2",counter++) ); 353 | await timeout(0); 354 | results.push( events.emit("test",counter++) ); 355 | await timeout(0); 356 | results.push( events.off("test",A) ); 357 | results.push( events.off("test",A) ); 358 | results.push( events.emit("test",counter++) ); 359 | await timeout(0); 360 | results.push( events.on("test",A) ); 361 | results.push( events.off("test",A) ); 362 | results.push( events.emit("test",counter++) ); 363 | await timeout(0); 364 | results.push( events.on("test",A) ); 365 | results.push( events.emit("test",counter++) ); 366 | results.push( events.off("test",A) ); 367 | await timeout(0); 368 | results.push( events.on("test",C) ); 369 | results.push( events.emit("test",counter++) ); 370 | results.push( events.on("test-2",D) ); 371 | await timeout(0); 372 | results.push( events.emit("test",counter++) ); 373 | results.push( events.off("test",C) ); 374 | results.push( events.off("test-2",D) ); 375 | results.push( events.once("test-2",D) ); 376 | await timeout(0); 377 | results.push( events.off("test-2",D) ); 378 | 379 | if (JSON.stringify(results) == JSON.stringify(expected)) { 380 | testResultsEl.innerHTML += "(Async Tests) PASSED."; 381 | return true; 382 | } 383 | else { 384 | testResultsEl.innerHTML += "(Async Tests) FAILED.

"; 385 | reportExpectedActual(expected,results); 386 | } 387 | } 388 | catch (err) { 389 | logError(err); 390 | testResultsEl.innerHTML += "(Async Tests) FAILED -- see console."; 391 | } 392 | return false; 393 | 394 | 395 | // *********************** 396 | 397 | function A(msg) { 398 | results.push(`A: ${msg}`); 399 | } 400 | 401 | function B(msg) { 402 | results.push(`B: ${msg}`); 403 | } 404 | 405 | function C(msg) { 406 | results.push(`C: ${msg}`); 407 | results.push( events.emit("test-2",counter++) ); 408 | } 409 | 410 | function D(msg) { 411 | results.push(`D: ${msg}`); 412 | if (msg > 0) { 413 | results.push( events.emit("test-2",-1 * (counter++)) ); 414 | } 415 | } 416 | } 417 | 418 | async function runWeakTestsPart1() { 419 | cleanupWeakTestsButtons(); 420 | 421 | testResultsEl.innerHTML = "Running weak tests (part 1)...
"; 422 | var expected = [ 423 | "A: 0", 424 | "B: 0", 425 | "C: 0", 426 | true, 427 | "A: 1", 428 | "B: 1", 429 | "C: 1", 430 | true, 431 | "A: 2", 432 | "B: 2", 433 | true, 434 | "A: 3", 435 | "B: 3", 436 | true, 437 | "D: 4", 438 | true, 439 | ]; 440 | weakTests.results = []; 441 | weakTests.listeners = { 442 | A(msg) { 443 | weakTests.results.push(`A: ${msg}`); 444 | }, 445 | B(msg) { 446 | weakTests.results.push(`B: ${msg}`); 447 | }, 448 | C(msg) { 449 | weakTests.results.push(`C: ${msg}`); 450 | }, 451 | D(msg) { 452 | weakTests.results.push(`D: ${msg}`); 453 | }, 454 | E(msg) { 455 | weakTests.results.push(`E: ${msg}`); 456 | }, 457 | }; 458 | var EController1 = new AbortController(); 459 | var ESignal1 = EController1.signal; 460 | var EController2 = new AbortController(); 461 | var ESignal2 = EController2.signal; 462 | weakTests.events1 = new Eventer({ asyncEmit: false, weakListeners: true, }); 463 | weakTests.events2 = new Eventer({ asyncEmit: false, weakListeners: false, }); 464 | weakTests.events3 = new Eventer({ asyncEmit: false, weakListeners: false, }); 465 | weakTests.finalization = new FinalizationRegistry( 466 | (val) => weakTests.results.push(`removed: ${val}`) 467 | ); 468 | weakTests.finalization.register(weakTests.listeners.A,"A"); 469 | weakTests.finalization.register(weakTests.listeners.B,"B"); 470 | weakTests.finalization.register(weakTests.listeners.C,"C"); 471 | weakTests.finalization.register(weakTests.listeners.D,"D"); 472 | weakTests.finalization.register(weakTests.events3,"events3"); 473 | weakTests.finalization.register(ESignal1,"E.signal.1"); 474 | weakTests.finalization.register(ESignal2,"E.signal.2"); 475 | 476 | try { 477 | var counter = 0; 478 | weakTests.events1.on("test",weakTests.listeners.A); 479 | weakTests.events1.on("test",weakTests.listeners.B); 480 | weakTests.events1.on("test",weakTests.listeners.C); 481 | weakTests.events1.once("test-2",weakTests.listeners.A); 482 | weakTests.events1.once("test-2",weakTests.listeners.B); 483 | weakTests.events1.once("test-3",weakTests.listeners.A); 484 | weakTests.events1.once("test-3",weakTests.listeners.B); 485 | weakTests.events2.on("test",weakTests.listeners.A); 486 | weakTests.events2.on("test",weakTests.listeners.B); 487 | weakTests.events2.on("test",weakTests.listeners.C); 488 | weakTests.events2.once("test-2",weakTests.listeners.A); 489 | weakTests.events2.once("test-2",weakTests.listeners.B); 490 | weakTests.events2.once("test-3",weakTests.listeners.A); 491 | weakTests.events2.once("test-3",weakTests.listeners.B); 492 | weakTests.events3.on("test",weakTests.listeners.D); 493 | 494 | weakTests.events1.on("test-4",weakTests.listeners.E,{ signal: ESignal1, }); 495 | weakTests.events1.on("test-5",weakTests.listeners.E,{ signal: ESignal1, }); 496 | weakTests.events1.on("test-6",weakTests.listeners.E,{ signal: ESignal2, }); 497 | 498 | weakTests.results.push( weakTests.events1.emit("test",counter++) ); 499 | weakTests.results.push( weakTests.events2.emit("test",counter++) ); 500 | weakTests.results.push( weakTests.events1.emit("test-2",counter++) ); 501 | weakTests.results.push( weakTests.events2.emit("test-2",counter++) ); 502 | weakTests.results.push( weakTests.events3.emit("test",counter++) ); 503 | 504 | if (JSON.stringify(weakTests.results) == JSON.stringify(expected)) { 505 | testResultsEl.innerHTML += "(Weak Tests Part 1) PASSED...
"; 506 | 507 | weakTests.results.length = 0; 508 | weakTests.events2.releaseListeners(weakTests.listeners.A); 509 | weakTests.events2.releaseListeners(); 510 | weakTests.events3 = null; 511 | weakTests.listeners = null; 512 | weakTests.EController1 = EController1; 513 | weakTests.EController2 = EController2; 514 | weakTests.ESignal1 = ESignal1; 515 | weakTests.ESignal2 = ESignal2; 516 | 517 | testResultsEl.innerHTML += ` 518 |
NEXT: Please trigger a GC event in the browser before running the part 2 tests. 519 |
520 | (see instructions above for Chrome or Firefox browsers) 521 |
522 | 523 |

524 | `; 525 | 526 | document.getElementById("run-weak-tests-part-2-btn").addEventListener("click",runWeakTestsPart2); 527 | return true; 528 | } 529 | else { 530 | testResultsEl.innerHTML += "(Weak Tests Part 1) FAILED.

"; 531 | reportExpectedActual(expected,weakTests.results); 532 | weakTests = {}; 533 | } 534 | } 535 | catch (err) { 536 | logError(err); 537 | testResultsEl.innerHTML = "(Weak Tests Part 1) FAILED -- see console."; 538 | weakTests = {}; 539 | } 540 | return false; 541 | } 542 | 543 | async function runWeakTestsPart2() { 544 | testResultsEl.innerHTML += "Running weak tests (part 2)...
"; 545 | var expected = [ 546 | "removed: A", 547 | "removed: B", 548 | "removed: C", 549 | "removed: D", 550 | "removed: events3", 551 | false, 552 | false, 553 | false, 554 | false, 555 | ]; 556 | 557 | try { 558 | var counter = 0; 559 | 560 | weakTests.results.push( weakTests.events1.emit("test",counter++) ); 561 | weakTests.results.push( weakTests.events2.emit("test",counter++) ); 562 | weakTests.results.push( weakTests.events1.off() ); 563 | weakTests.results.push( weakTests.events2.off() ); 564 | 565 | // normalize unpredictable finalization-event ordering 566 | weakTests.results.sort((v1,v2) => ( 567 | typeof v1 == "string" ? ( 568 | typeof v2 == "string" ? v1.localeCompare(v2) : 0 569 | ) : 1 570 | )); 571 | 572 | if (JSON.stringify(weakTests.results) == JSON.stringify(expected)) { 573 | testResultsEl.innerHTML += "(Weak Tests Part 2) PASSED.
"; 574 | 575 | weakTests.results.length = 0; 576 | // allow GC of abort-controllers/signals (for part 3) 577 | weakTests.EController1 = weakTests.ESignal1 = 578 | weakTests.EController2 = weakTests.ESignal2 = null; 579 | 580 | 581 | testResultsEl.innerHTML += ` 582 |
LASTLY: Please trigger *ONE MORE* GC event in the browser before running the part 3 tests. 583 |
584 | (see instructions above for Chrome or Firefox browsers) 585 |
586 | 587 |

588 | `; 589 | 590 | document.getElementById("run-weak-tests-part-3-btn").addEventListener("click",runWeakTestsPart3); 591 | return true; 592 | } 593 | else { 594 | testResultsEl.innerHTML += "(Weak Tests Part 2) FAILED.

"; 595 | reportExpectedActual(expected,weakTests.results); 596 | } 597 | } 598 | catch (err) { 599 | logError(err); 600 | testResultsEl.innerHTML = "(Weak Tests Part 2) FAILED -- see console."; 601 | } 602 | finally { 603 | cleanupWeakTestsButtons(true,false); 604 | } 605 | return false; 606 | } 607 | 608 | async function runWeakTestsPart3() { 609 | testResultsEl.innerHTML += "Running weak tests (part 3)...
"; 610 | var expected = [ 611 | "removed: E.signal.1", 612 | "removed: E.signal.2", 613 | ]; 614 | weakTests.finalization = null; 615 | 616 | try { 617 | // normalize unpredictable finalization-event ordering 618 | weakTests.results.sort((v1,v2) => ( 619 | typeof v1 == "string" ? ( 620 | typeof v2 == "string" ? v1.localeCompare(v2) : 0 621 | ) : 1 622 | )); 623 | 624 | if (JSON.stringify(weakTests.results) == JSON.stringify(expected)) { 625 | testResultsEl.innerHTML += "(Weak Tests Part 3) PASSED.
"; 626 | return true; 627 | } 628 | else { 629 | testResultsEl.innerHTML += "(Weak Tests Part 3) FAILED.

"; 630 | reportExpectedActual(expected,weakTests.results); 631 | } 632 | } 633 | catch (err) { 634 | logError(err); 635 | testResultsEl.innerHTML = "(Weak Tests Part 3) FAILED -- see console."; 636 | } 637 | finally { 638 | cleanupWeakTestsButtons(); 639 | weakTests = {}; 640 | } 641 | return false; 642 | } 643 | 644 | function cleanupWeakTestsButtons(part2 = true,part3 = true) { 645 | if (part2) { 646 | let btn1 = document.getElementById("run-weak-tests-part-2-btn"); 647 | if (btn1 != null) { 648 | btn1.disabled = true; 649 | btn1.removeEventListener("click",runWeakTestsPart2); 650 | } 651 | } 652 | if (part3) { 653 | let btn2 = document.getElementById("run-weak-tests-part-3-btn"); 654 | if (btn2 != null) { 655 | btn2.disabled = true; 656 | btn2.removeEventListener("click",runWeakTestsPart3); 657 | } 658 | } 659 | } 660 | 661 | function timeout(ms) { 662 | return new Promise(res => setTimeout(res,ms)); 663 | } 664 | 665 | function reportExpectedActual(expected,results) { 666 | var expectedStr = expected.join(); 667 | var resultsStr = results.join(); 668 | var matchStr = findLeadingSimilarity(expectedStr,resultsStr); 669 | if (matchStr != "") { 670 | expectedStr = `${matchStr}${expectedStr.slice(matchStr.length)}`; 671 | resultsStr = `${matchStr}${resultsStr.slice(matchStr.length)}`; 672 | } 673 | testResultsEl.innerHTML += `EXPECTED
${expectedStr}

ACTUAL
${resultsStr}
`; 674 | } 675 | 676 | function findLeadingSimilarity(str1,str2) { 677 | for (let i = 0; i < str1.length && i < str2.length; i++) { 678 | if (str1[i] != str2[i]) { 679 | return str1.substr(0,i); 680 | } 681 | } 682 | return ( 683 | str1.length < str2.length ? str1 : str2 684 | ); 685 | } 686 | 687 | function logError(err,returnLog = false) { 688 | var err = `${ 689 | err.stack ? err.stack : err.toString() 690 | }${ 691 | err.cause ? `\n${logError(err.cause,/*returnLog=*/true)}` : "" 692 | }`; 693 | if (returnLog) return err; 694 | else console.error(err); 695 | } 696 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eventer 2 | 3 | [![npm Module](https://badge.fury.io/js/@byojs%2Feventer.svg)](https://www.npmjs.org/package/@byojs/eventer) 4 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 5 | 6 | **Eventer** is a zero-dependency event emitter, with optional support for async `emit()`, and [weak event listeners](WEAK.md). 7 | 8 | ```js 9 | const onUpdate = data => { 10 | console.log(`Data updated: ${data}`); 11 | }; 12 | 13 | events.on("update",onUpdate); 14 | 15 | events.emit("update",{ hello: "world" }) 16 | // Data updated: { hello: "world" } 17 | ``` 18 | 19 | ---- 20 | 21 | [Library Tests (Demo)](https://byojs.dev/eventer/) 22 | 23 | ---- 24 | 25 | ## Overview 26 | 27 | The main purpose of **Eventer** is to provide a basic event emitter that supports two specific helpful features that most event emitters (in JS land) do not have: 28 | 29 | 1. async `emit()`: asynchronous event handling sometimes makes it easier to work around difficult issues with event handling. 30 | 31 | For example, if the listener for one event subscribes or unsubscribes other event handlers, you can run into events that fire when they shouldn't (or vice versa). Or you may encounter infinite event loops (events calling each other mutually, for ever). 32 | 33 | On the other hand, asynchrony is always more intricate to manage propperly. Developers should use caution when deciding how to handle events. 34 | 35 | **Eventer** supports both *sync* and *async* modes for event emission; this mode is configured at emitter instance creation instead of at every `emit()` call. 36 | 37 | 2. [weak event listeners](WEAK.md): this is a pattern for managing the subscription of events, which holds a reference to the listener (function) *weakly*; the emitter instance **DOES NOT** prevent the listener function -- and particularly, anything the function has a closure over! -- from being cleaned up by GC (garbage collection). 38 | 39 | Typically, developers have to remember to remove an event subscription if the listener (or any object it belongs to) is intentionally being unset for GC purposes; otherwise, an event emitter's default *strong reference* keeps that listener value (and its closure!) alive, preventing GC. 40 | 41 | **Eventer** supports both *strong* and *weak* modes for listener subscription; this mode is configured at emitter instance creation instead of every `on()` / `once()` call. 42 | 43 | ## Deployment / Import 44 | 45 | ```cmd 46 | npm install @byojs/eventer 47 | ``` 48 | 49 | The [**@byojs/eventer** npm package](https://npmjs.com/package/@byojs/eventer) includes a `dist/` directory with all files you need to deploy **Eventer** (and its dependencies) into your application/project. 50 | 51 | **Note:** If you obtain this library via git instead of npm, you'll need to [build `dist/` manually](#re-building-dist) before deployment. 52 | 53 | ### Using a bundler 54 | 55 | If you are using a bundler (Astro, Vite, Webpack, etc) for your web application, you should not need to manually copy any files from `dist/`. 56 | 57 | Just `import` like so: 58 | 59 | ```js 60 | import Eventer from "@byojs/eventer"; 61 | ``` 62 | 63 | The bundler tool should pick up and find whatever files (and dependencies) are needed. 64 | 65 | ### Without using a bundler 66 | 67 | If you are not using a bundler (Astro, Vite, Webpack, etc) for your web application, and just deploying the contents of `dist/` as-is without changes (e.g., to `/path/to/js-assets/eventer/`), you'll need an [Import Map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) in your app's HTML: 68 | 69 | ```html 70 | 77 | ``` 78 | 79 | Now, you'll be able to `import` the library in your app in a friendly/readable way: 80 | 81 | ```js 82 | import Eventer from "eventer"; 83 | ``` 84 | 85 | **Note:** If you omit the above *eventer* import-map entry, you can still `import` **Eventer** by specifying the proper full path to the `eventer.mjs` file. 86 | 87 | ## Eventer API 88 | 89 | The API provided by **Eventer** is a single constructor function, to create emitter instances: 90 | 91 | ```js 92 | import Eventer from ".."; 93 | 94 | var events = new Eventer({ /* options */ }); 95 | ``` 96 | 97 | The options that can be passed to the constructor: 98 | 99 | * `asyncEmit` (default: `false`): controls whether `emit()` calls will immediately trigger event listeners, or wait for the next asynchronous microtask to trigger them. 100 | 101 | * `weakListeners` (default: `true`): controls whether any listeners (function callbacks) are held *strongly* (as typical) or [*weakly* (for more advanced memory management)](WEAK.md). 102 | 103 | ### Class-based composition 104 | 105 | The exposed API function (`Eventer()` above) can act as a constructable `class` (as seen with the `new` call), which means it can be used in an `extends` clause of a child/derived class: 106 | 107 | ```js 108 | class myGreatStuff extends Eventer { 109 | constructor(eventerOptions,otherOptions) { 110 | super(eventerOptions); 111 | // .. 112 | } 113 | 114 | // .. 115 | } 116 | 117 | var thing = new myGreatStuff(..); 118 | 119 | thing instanceof MyGreatStuff; // true 120 | thing instanceof Eventer; // true 121 | 122 | thing.emit("whatever"); 123 | ``` 124 | 125 | *Composition through inheritance* essentially *mixes in* event emitter capabilities to your own data structure definition. Many prefer this approach. 126 | 127 | Others prefer a more explicit form of *composition* (over/instead of inheritance) to maintain an **Eventer** instance as a clean, separate object. For example: 128 | 129 | ```js 130 | class myGreatStuff { 131 | eventer = new Eventer(..) 132 | 133 | // .. 134 | } 135 | 136 | var thing = new myGreatStuff(..); 137 | 138 | thing.eventer.emit("whatever"); 139 | ``` 140 | 141 | ### Without classes 142 | 143 | If you're not using `Eventer()` as an inheritable parent class, you don't really have to use `class` design at all. 144 | 145 | In fact, `Eventer()` can be called as a *factory function* without `new`, if you prefer: 146 | 147 | ```js 148 | var events = Eventer({ /* options */ }); 149 | ``` 150 | 151 | ### Be aware of `this`! 152 | 153 | Even if `Eventer()` is called without `new`, a class instance is still created underneath. That means that the methods on the returned object instance (e.g., `events.emit(..)`) are `this`-aware of their host context (instance). 154 | 155 | The following approaches *will break*: 156 | 157 | ```js 158 | var myEmit = events.emit; 159 | 160 | myEmit("whatever"); // broken! 161 | ``` 162 | 163 | ```js 164 | someAsyncTask().then(events.emit); // broken! 165 | ``` 166 | 167 | ```js 168 | events.emit.call(myOtherObject,"whatever"); // broken! 169 | ``` 170 | 171 | Instead, you'll need to ensure methods are always called against their original instance as `this` context. 172 | 173 | ```js 174 | events.emit("whatever"); // safe, preferred 175 | ``` 176 | 177 | ```js 178 | var myEmit = events.emit; 179 | 180 | myEmit.call(events,"whatever"); // safe 181 | ``` 182 | 183 | ```js 184 | someAsyncTask().then( 185 | evtName => events.emit(evtName) // safe, preferred 186 | ); 187 | 188 | someAsyncTask().then( 189 | events.emit.bind(events) // safe 190 | ); 191 | ``` 192 | 193 | ## Instance API 194 | 195 | Each instance of **Eventer** provides the following methods. 196 | 197 | ### `on(..)` Method 198 | 199 | The `on(..)` method subscribes a listener (function) to an event (by string name, or `Symbol` value): 200 | 201 | ```js 202 | function onWhatever() { 203 | console.log("'whatever' event fired!"); 204 | } 205 | 206 | // subscribe to "whatever" event 207 | events.on("whatever",onWhatever); 208 | ``` 209 | 210 | ```js 211 | function onSpecialEvent() { 212 | console.log("special event fired!"); 213 | } 214 | 215 | // subscribe to `specialEvent` event 216 | var specialEvent = Symbol("special event"); 217 | events.on(specialEvent,onSpecialEvent); 218 | ``` 219 | 220 | Event listener functions are invoked with `this`-context of the emitter instance, *if possible*; `=>` arrow functions never have `this` binding, and already `this`-hard-bound (via `.bind(..)`) functions cannot be `this`-overridden -- and `class` constructors require `new` invocation! 221 | 222 | Event subscriptions must be unique, meaning the event+listener combination must not have already been subscribed. This makes **Eventer** safer, preventing duplicate event subscriptions -- a common bug in event-oriented program design. 223 | 224 | The `on(..)` method returns `true` if successfully subscribed, or `false` (if subscription was skipped). 225 | 226 | ### Event arguments 227 | 228 | An event listener function may optionally declare one or more parameters, which are passed in as arguments when the event is [`emit(..)`ed](#emit-method). 229 | 230 | For example: 231 | 232 | ```js 233 | function onPositionUpdate(x,y) { 234 | console.log(`Map position: (${x},${y})`); 235 | } 236 | 237 | myMap.on("position-update",onPositionUpdate); 238 | 239 | // elsewhere: 240 | myMap.emit("position-update",centerX,centerY); 241 | ``` 242 | 243 | ### `AbortSignal` unsubscription 244 | 245 | A recent welcomed change to the [native `addEventListener(..)` browser API](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) is the ability to pass in an [`AbortSignal` instance](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) (from an [`AbortController` instance](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)); if the `"abort"` event is fired, [the associated event listener is unsubscribed](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#signal), instead of having to manually call [`removeEventListener(..)`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener) to unsubscribe. This is helpful because you don't need keep around any reference to the listener function to unsubscribe it. 246 | 247 | **Eventer** also supports this functionality: 248 | 249 | ```js 250 | function onWhatever() { 251 | console.log("'whatever' event fired!"); 252 | } 253 | 254 | var ac = new AbortController(); 255 | 256 | // subscribe to "whatever" event, but set up 257 | // the abort-signal to unsubscribe 258 | events.on("whatever",onWhatever,{ signal: ac.signal }); 259 | 260 | // later: 261 | ac.abort("Unsubscribe!"); 262 | ``` 263 | 264 | **Note:** An `AbortSignal` instance is also held weakly by **Eventer**, so any GC of either the listener or the signal will drop the relationship between them as desired -- without preventing GC of each other. 265 | 266 | ### Inline event listeners (functions) 267 | 268 | It's very common in modern JS programming, and especially with event handling code, to pass inline functions (e.g., `=>` arrow functions) as event listeners. However, there are some very important details/gotchas to be aware of when doing so with **Eventer**. 269 | 270 | #### NOT inline event listeners 271 | 272 | Before we explain those gotchas, let's highlight the preferred alternatives to inline functions (as already implied in previous snippets!): 273 | 274 | ```js 275 | function onWhatever() { 276 | // this is a safe and stable event listener 277 | console.log("'whatever' event fired!"); 278 | } 279 | 280 | events.on("whatever",onWhatever); 281 | ``` 282 | 283 | ```js 284 | var myApp = { 285 | // .. 286 | onWhatever() { 287 | // this is a safe and stable event listener, 288 | // as long as it's not `this`-dependent 289 | console.log("'whatever' event fired!"); 290 | } 291 | // .. 292 | }; 293 | 294 | events.on("whatever",myApp.onWhatever); 295 | ``` 296 | 297 | ```js 298 | class App { 299 | // .. 300 | onWhatever = () => { 301 | // this is a safe and stable event listener, 302 | // even if it uses `this` (since it's a 303 | // lexical-`this` arrow function) 304 | console.log("'whatever' event fired!"); 305 | } 306 | // .. 307 | } 308 | 309 | var myApp = new App(); 310 | 311 | events.on("whatever",myApp.onWhatever); 312 | ``` 313 | 314 | All of these approaches are *safe* and avoid the issues we will now cover with using inline function listeners. 315 | 316 | #### Inline handler gotchas 317 | 318 | First of all, the subscription (`on(..)` / `once(..)`) mechanism uses function reference identity to determine uniqueness of event+listener subscription. If you pass an inline function expression (or a dynamically `this`-bound function instance), each subscription will use a new function; the duplicate-subscription prevention will be defeated, potentially leading to bugs. 319 | 320 | For example: 321 | 322 | ```js 323 | function listenToWhatever() { 324 | events.on( 325 | "whatever", 326 | () => console.log("'whatever' event fired!") 327 | ); 328 | } 329 | 330 | listenToWhatever(); 331 | 332 | // later, elsewhere: 333 | listenToWhatever(); 334 | ``` 335 | 336 | Here, each `=>` arrow function is unique (per `listenToWhatever()` call), so there are now two distinct event subscriptions. When the `"whatever"` event is fired, *both* listeners will fire. This may be desired, but it's often a confusing gotcha bug. 337 | 338 | It's generally a good idea to pass non-inline functions (with stable definitions), as listeners; this enables **Eventer**'s helpful duplicate event handler prevention. 339 | 340 | #### Unsubscribe what? 341 | 342 | Another concern with passing inline functions as listeners: the most common/preferred [`off(..)` unsubscription approach](#off-method) requires the same function reference for unsubscription as was originally subscribed. You almost certainly will not hold another reference to an inline function -- by definition, it was defined only *inline* at the subscription site -- to use in its later unsubscription. 343 | 344 | ```js 345 | events.on( 346 | "whatever", 347 | () => console.log("'whatever' event fired!") 348 | ); 349 | 350 | // later: 351 | events.off("whatever", /* OOPS, what do I pass here!? */) 352 | ``` 353 | 354 | **Note:** This unsubscription concern is not *unworkable*, though. There are [other ways to use `off(..)` unsubscription](#alternate-unsubscription) that avoid this issue, or you can [use an `AbortSignal` to unsubscribe](#abortsignal-unsubscription). 355 | 356 | #### Accidental unsubscription 357 | 358 | The most pressing concern with inline event listeners arises when using the [*weak event listeners* mode](WEAK.md). Since this is the *default* mode of **Eventer**, it's of particular importance to be aware of this *very likely* gotcha. 359 | 360 | Since there is almost certainly no other reference to an inline function reference other than the one passed into `on(..)` / `once(..)`, once the lexical scope (i.e., surrounding function, etc) of the subscription has finished, and its contents are now subject to GC cleanup, the **listener function itself** will likely be GC removed. 361 | 362 | By design, **Eventer**'s [*weak event listeners* mode](WEAK.md) ensures event subscriptions are discarded if the listener itself is GC'd. This helps prevent accidental memory leaks when forgetting to unsubscribe events that are no longer relevant. 363 | 364 | However, GC is *inherently and intentionally* somewhat unpredictable. It's not guaranteed, or even likely, that GC will happen immediately on a lexical scope being completed; it *may* happen sometime in the near future -- and, only if there are no intentional or accidental closures keeping all or part of the lexical scope alive! 365 | 366 | That means your event subscriptions with inline functions **are subject to fairly unpredictable behavior**. They may fire for awhile and then silently stop, even with no further affirmative action from your controlling app code. 367 | 368 | For illustration: 369 | 370 | ```js 371 | function listenToWhatever() { 372 | events.on( 373 | "whatever", 374 | () => console.log("'whatever' event fired!") 375 | ); 376 | } 377 | 378 | listenToWhatever(); 379 | ``` 380 | 381 | After the call to `listenToWhatever()`, any `"whatever"` events fired, may be handled or not, unpredictably, because the inner `=>` arrow function is now subject to GC cleanup at any point the JS engine feels like it! 382 | 383 | Hopefully it's clear that you should avoid inline function listeners, at least when using the *weak event listeners* mode of **Eventer**. 384 | 385 | ### `once(..)` Method 386 | 387 | The `once(..)` method subscribes like [`on(..)`](#on-method), except that as soons as the event is emitted the first time, the listener is unsubscribed. This guarantees a specific event+listener will first *at most* "once". 388 | 389 | ```js 390 | function onWhateverOnce() { 391 | console.log("'whatever' event fired (just once)!"); 392 | } 393 | 394 | // subscribe to "whatever" event, but only once! 395 | events.once("whatever",onWhateverOnce); 396 | ``` 397 | 398 | `once(..)` and `on(..)` perform the same kind of event subscription. Subsequent calls of `once(..)` or `on(..)` (in any combination) will skip any subsequent subscriptions (returning `false`). You cannot *switch* from `on(..)` to `once(..)` style subscription (or vice versa) by calling one method after the other (with the same event+listener); to switch, you must first [unsubscribe with `off(..)`](#off-method) before re-subscribing. 399 | 400 | ### `off(..)` Method 401 | 402 | The `off(..)` method unsubscribes an event+listener that was previously subscribed with the [`on(..)`](#on-method) or [`once(..)`](#once-method) methods. 403 | 404 | ```js 405 | function onWhatever() { 406 | console.log("'whatever' event fired!"); 407 | } 408 | 409 | // unsubscribe from "whatever" event 410 | events.off("whatever",onWhatever); 411 | ``` 412 | 413 | The method will return `true` if the event was unsubscribed, or `false` if no matching event+listener subscription could be found. 414 | 415 | #### Alternate unsubscription 416 | 417 | The two arguments to `off(..)` are *both optional*. 418 | 419 | If you pass only the first *event-name* argument, but leave off the listener argument, all liseners for that event will be removed: 420 | 421 | ```js 422 | // remove any 'whatever' listeners 423 | events.off("whatever"); 424 | ``` 425 | 426 | `true` will be returned if any event listeners are currently subscribed, or `false` otherwise. 427 | 428 | If you instead pass only the second *listener* argument (with `null` or `undefined` for the first *event-name* argument), it will unsubscribe *all events* that have included that specific listener: 429 | 430 | ```js 431 | function onEvent() { /* .. */ } 432 | 433 | events.off(null,onEvent); 434 | ``` 435 | 436 | `true` will be returned if the listener is subscribed to any events, or `false` otherwise. 437 | 438 | If you call `off()` with no arguments, *all events* with *any event listeners* are unsubscribed: 439 | 440 | ```js 441 | // clear out all event subscriptions unconditionally! 442 | events.off(); 443 | ``` 444 | 445 | `true` will be returned if any event+listener subscription is found to remove, or `false` otherwise. 446 | 447 | ### `emit(..)` Method 448 | 449 | To *emit* an event against all listeners on an emitter instance, call `emit(..)`: 450 | 451 | ```js 452 | events.emit("whatever"); 453 | ``` 454 | 455 | ```js 456 | // specialEvent: Symbol("special event") 457 | 458 | events.emit(specialEvent); 459 | ``` 460 | 461 | **Note:** If a listener function throws an exception, this error will be reported to the consolve (via `console.error()`), but will not stop the `emit()` call. All handlers will be given a fair chance to execute. 462 | 463 | Any subscription/unsubscription operations *from/during a listener execution* will NOT take effect until after all event listeners queued by `emit()` have had a chance to be invoked ([synchronously or asynchronously](#sync-vs-async-modes)). 464 | 465 | You can optionally pass one or more arguments after the event name, which will be passed to the event listener(s): 466 | 467 | ```js 468 | events.emit("whatever",42,[ "hello", "world" ]); 469 | ``` 470 | 471 | **Note:** This call will pass *two* arguments to the listener function(s), `42` and the array `["hello","world"]`. 472 | 473 | #### Sync vs Async modes 474 | 475 | If the emitter is in *sync-emit* mode (default, [configured at instance construction](#eventer-api)), any matching listener function(s) will be called synchronously during the `emit(..)` call. 476 | 477 | ```js 478 | function onWhatever() { 479 | console.log("'whatever' event fired!"); 480 | } 481 | 482 | var events = new Eventer({ asyncEmit: false }); 483 | 484 | events.on("whatever",onWhatever); 485 | events.emit("whatever"); 486 | console.log("Done."); 487 | // 'whatever' event fired! 488 | // Done. 489 | ``` 490 | 491 | **Note:** The `emit()` call in *sync mode* invokes all event listeners while it is running, which is why `Done.` message is printed last. 492 | 493 | If the emitter is in *async-emit* mode ([configured at instance construction](#eventer-api)), any matching listener function(s) **at the time of `emit()` call** will be asynchronously scheduled for the next microtask. However, `emit()` always still completes immediately. 494 | 495 | ```js 496 | function onWhatever() { 497 | console.log("'whatever' event fired!"); 498 | } 499 | 500 | var events = new Eventer({ asyncEmit: true }); 501 | 502 | events.on("whatever",onWhatever); 503 | events.emit("whatever"); 504 | console.log("Done."); 505 | // Done. 506 | // 'whatever' event fired! 507 | ``` 508 | 509 | **Note:** Here (async mode), the `Done.` message is printed first, because the current stack of execution completes before the next microtask runs (and processes async scheduled event listener invocations). 510 | 511 | ### `releaseListeners(..)` Method 512 | 513 | If using [*weak event listeners* mode](WEAK.md) (default), the `releaseListeners(..)` method is no-op (does nothing). 514 | 515 | But if that mode is turned off (i.e., *strong event listeners* mode), the `releaseListeners(..)` mode can be used to release a specific listener, or all listeners if no argument is passed. 516 | 517 | ```js 518 | // release specific event listener 519 | events.releaseListeners(onWhatever); 520 | ``` 521 | 522 | ```js 523 | // release all event listeners 524 | events.releaseListeners(); 525 | ``` 526 | 527 | This method is intended for use as proactive-cleanup, under the specific circumstance when you know that the subscribed listener(s) in question *will go out of scope* (and otherwise be GC'd) in the future, and you want the event(s)+listener(s) to be implicitly unsubscribed when doing so. 528 | 529 | In other words, it's a way to opt-in to [*weak event listeners* mode](WEAK.md), on an otherwise *strong event listeners* mode emitter instance, but **only for currently subscribed listeners** (not future subscriptions on the instance). 530 | 531 | This differs from calling `off(null,onWhatever)` / `off()` in that `releaseListeners()` *does not* affirmatively unsubscribe the events (as `off(..)` does), but merely *allow* future implicit unsubscription. 532 | 533 | **Note:** If you're in the circumstance where all listener(s) have already gone out of scope, and you might be tempted to call `releaseListeners()` (no arguments) to allow the GC, this circumstance is better suited to use `off()` (no arguments) instead. 534 | 535 | ## Re-building `dist/*` 536 | 537 | If you need to rebuild the `dist/*` files for any reason, run: 538 | 539 | ```cmd 540 | # only needed one time 541 | npm install 542 | 543 | npm run build:all 544 | ``` 545 | 546 | ## Tests 547 | 548 | This library only works in a browser, so its test suite must also be run in a browser. 549 | 550 | Visit [`https://byojs.dev/eventer/`](https://byojs.dev/eventer/) and click the "run tests" button. 551 | 552 | ### Run Locally 553 | 554 | To instead run the tests locally, first make sure you've [already run the build](#re-building-dist), then: 555 | 556 | ```cmd 557 | npm test 558 | ``` 559 | 560 | This will start a static file webserver (no server logic), serving the interactive test page from `http://localhost:8080/`; visit this page in your browser and click the "run tests" button. 561 | 562 | By default, the `test/test.js` file imports the code from the `src/*` directly. However, to test against the `dist/*` files (as included in the npm package), you can modify `test/test.js`, updating the `/src` in its `import` statements to `/dist` (see the import-map in `test/index.html` for more details). 563 | 564 | ## License 565 | 566 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 567 | 568 | All code and documentation are (c) 2024 Kyle Simpson and released under the [MIT License](http://getify.mit-license.org/). A copy of the MIT License [is also included](LICENSE.txt). 569 | --------------------------------------------------------------------------------