├── size.txt ├── .npmignore ├── .gitignore ├── scripts └── size-calc.js ├── tslint.json ├── rollup.config.ts ├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ └── plan-release.yml ├── CHANGELOG.md ├── src ├── registry.ts ├── notification.ts └── index.ts ├── .release-plan.json ├── RELEASE.md ├── package.json ├── test └── index.test.ts ├── README.md └── tsconfig.json /size.txt: -------------------------------------------------------------------------------- 1 | intersection-observer-admin.js is 3.47 kB gzipped. -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | rollup.config.ts 4 | tslint.json 5 | .travis.yml 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # docs 2 | docs/* 3 | .rpt2_cache 4 | 5 | # typescript 6 | dist 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # misc 12 | /connect.lock 13 | /coverage/* 14 | npm-debug.log* 15 | yarn-error.log 16 | .vscode 17 | -------------------------------------------------------------------------------- /scripts/size-calc.js: -------------------------------------------------------------------------------- 1 | var gzipSize = require('gzip-size'); 2 | var fs = require('fs'); 3 | var prettyBytes = require('pretty-bytes'); 4 | 5 | var contents = fs.readFileSync('./dist/intersection-observer-admin.es5.js'); 6 | var kb = prettyBytes(gzipSize.sync(contents)); 7 | var msg = 'intersection-observer-admin.js is ' + kb + ' gzipped.'; 8 | 9 | fs.writeFileSync('./size.txt', msg); 10 | 11 | console.log(msg); 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-eslint-rules", 6 | "tslint-config-prettier" 7 | ], 8 | "jsRules": {}, 9 | "rules": { 10 | "variable-name": false, 11 | "member-access": false, 12 | "no-unused-variable": true, 13 | "prettier": [ 14 | true, 15 | { 16 | "singleQuote": true 17 | } 18 | ], 19 | "interface-over-type-literal": false, 20 | "ban-types": false, 21 | "array-type": false, 22 | "no-empty": false 23 | }, 24 | "rulesDirectory": [ 25 | "tslint-plugin-prettier" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import camelCase from 'lodash.camelcase'; 3 | import sourceMaps from 'rollup-plugin-sourcemaps'; 4 | 5 | const pkg = require('./package.json'); 6 | const libraryName = 'intersection-observer-admin'; 7 | 8 | export default { 9 | external: [], 10 | input: `dist/es/index.js`, 11 | output: [ 12 | { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, 13 | { file: pkg.module, format: 'es', sourcemap: true } 14 | ], 15 | plugins: [ 16 | typescript(), 17 | // Resolve source maps to the original source 18 | sourceMaps() 19 | ], 20 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 21 | watch: { 22 | include: 'dist/es/**' 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | jobs: 11 | 12 | test: 13 | name: "Tests" 14 | env: 15 | CI: true 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Install node 20 | uses: actions/setup-node@v2-beta 21 | with: 22 | node-version: 12.x 23 | 24 | - name: Cache node modules 25 | uses: actions/cache@v2 26 | env: 27 | cache-name: cache-node-modules 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.os }}-lint-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-build-${{ env.cache-name }}- 33 | ${{ runner.os }}-build- 34 | ${{ runner.os }}- 35 | - name: npm install 36 | run: npm install 37 | - name: lint js 38 | run: npm run lint 39 | - name: test 40 | run: npm run test 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release (2024-07-15) 4 | 5 | intersection-observer-admin 0.3.4 (patch) 6 | 7 | #### :bug: Bug Fix 8 | * `intersection-observer-admin` 9 | * [#58](https://github.com/snewcomer/intersection-observer-admin/pull/58) feat(memory-leaks): remove elements from the window root ([@BobrImperator](https://github.com/BobrImperator)) 10 | * [#55](https://github.com/snewcomer/intersection-observer-admin/pull/55) feat(memory-leaks): remove elements from registries when unobserve is called ([@BobrImperator](https://github.com/BobrImperator)) 11 | * [#49](https://github.com/snewcomer/intersection-observer-admin/pull/49) fix leak by releasing ref to observed element ([@SergeAstapov](https://github.com/SergeAstapov)) 12 | 13 | #### :house: Internal 14 | * `intersection-observer-admin` 15 | * [#57](https://github.com/snewcomer/intersection-observer-admin/pull/57) add release-plan ([@mansona](https://github.com/mansona)) 16 | 17 | #### Committers: 3 18 | - Bartlomiej Dudzik ([@BobrImperator](https://github.com/BobrImperator)) 19 | - Chris Manson ([@mansona](https://github.com/mansona)) 20 | - Sergey Astapov ([@SergeAstapov](https://github.com/SergeAstapov)) 21 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | // fix by importing IOptions 2 | export interface IOptions { 3 | [key: string]: any; 4 | } 5 | 6 | export default class Registry { 7 | public registry: WeakMap; 8 | 9 | constructor() { 10 | this.registry = new WeakMap(); 11 | } 12 | 13 | public elementExists(elem: HTMLElement | Window): boolean | null { 14 | return this.registry.has(elem); 15 | } 16 | 17 | public getElement(elem: HTMLElement | Window): any { 18 | return this.registry.get(elem); 19 | } 20 | 21 | /** 22 | * administrator for lookup in the future 23 | * 24 | * @method add 25 | * @param {HTMLElement | Window} element - the item to add to root element registry 26 | * @param {IOption} options 27 | * @param {IOption.root} [root] - contains optional root e.g. window, container div, etc 28 | * @param {IOption.watcher} [observer] - optional 29 | * @public 30 | */ 31 | public addElement(element: HTMLElement | Window, options?: IOptions): void { 32 | if (!element) { 33 | return; 34 | } 35 | 36 | this.registry.set(element, options || {}); 37 | } 38 | 39 | /** 40 | * @method remove 41 | * @param {HTMLElement|Window} target 42 | * @public 43 | */ 44 | public removeElement(target: HTMLElement | Window): void { 45 | this.registry.delete(target); 46 | } 47 | 48 | /** 49 | * reset weak map 50 | * 51 | * @method destroy 52 | * @public 53 | */ 54 | public destroyRegistry(): void { 55 | this.registry = new WeakMap(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "intersection-observer-admin": { 4 | "impact": "patch", 5 | "oldVersion": "0.3.3", 6 | "newVersion": "0.3.4", 7 | "constraints": [ 8 | { 9 | "impact": "patch", 10 | "reason": "Appears in changelog section :bug: Bug Fix" 11 | }, 12 | { 13 | "impact": "patch", 14 | "reason": "Appears in changelog section :house: Internal" 15 | } 16 | ], 17 | "pkgJSONPath": "./package.json" 18 | } 19 | }, 20 | "description": "## Release (2024-07-15)\n\nintersection-observer-admin 0.3.4 (patch)\n\n#### :bug: Bug Fix\n* `intersection-observer-admin`\n * [#58](https://github.com/snewcomer/intersection-observer-admin/pull/58) feat(memory-leaks): remove elements from the window root ([@BobrImperator](https://github.com/BobrImperator))\n * [#55](https://github.com/snewcomer/intersection-observer-admin/pull/55) feat(memory-leaks): remove elements from registries when unobserve is called ([@BobrImperator](https://github.com/BobrImperator))\n * [#49](https://github.com/snewcomer/intersection-observer-admin/pull/49) fix leak by releasing ref to observed element ([@SergeAstapov](https://github.com/SergeAstapov))\n\n#### :house: Internal\n* `intersection-observer-admin`\n * [#57](https://github.com/snewcomer/intersection-observer-admin/pull/57) add release-plan ([@mansona](https://github.com/mansona))\n\n#### Committers: 3\n- Bartlomiej Dudzik ([@BobrImperator](https://github.com/BobrImperator))\n- Chris Manson ([@mansona](https://github.com/mansona))\n- Sergey Astapov ([@SergeAstapov](https://github.com/SergeAstapov))\n" 21 | } 22 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases in this repo are mostly automated using [release-plan](https://github.com/embroider-build/release-plan/). Once you label all your PRs correctly (see below) you will have an automatically generated PR that updates your CHANGELOG.md file and a `.release-plan.json` that is used to prepare the release once the PR is merged. 4 | 5 | ## Preparation 6 | 7 | Since the majority of the actual release process is automated, the remaining tasks before releasing are: 8 | 9 | - correctly labeling **all** pull requests that have been merged since the last release 10 | - updating pull request titles so they make sense to our users 11 | 12 | Some great information on why this is important can be found at [keepachangelog.com](https://keepachangelog.com/en/1.1.0/), but the overall 13 | guiding principle here is that changelogs are for humans, not machines. 14 | 15 | When reviewing merged PR's the labels to be used are: 16 | 17 | * breaking - Used when the PR is considered a breaking change. 18 | * enhancement - Used when the PR adds a new feature or enhancement. 19 | * bug - Used when the PR fixes a bug included in a previous release. 20 | * documentation - Used when the PR adds or updates documentation. 21 | * internal - Internal changes or things that don't fit in any other category. 22 | 23 | **Note:** `release-plan` requires that **all** PRs are labeled. If a PR doesn't fit in a category it's fine to label it as `internal` 24 | 25 | ## Release 26 | 27 | Once the prep work is completed, the actual release is straight forward: you just need to merge the open [Plan Release](https://github.com/snewcomer/intersection-observer-admin/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For every push to the master branch, this checks if the release-plan was 2 | # updated and if it was it will publish stable npm packages based on the 3 | # release plan 4 | 5 | name: Publish Stable 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | 14 | concurrency: 15 | group: publish-${{ github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | check-plan: 20 | name: "Check Release Plan" 21 | runs-on: ubuntu-latest 22 | outputs: 23 | command: ${{ steps.check-release.outputs.command }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | ref: 'master' 30 | # This will only cause the `check-plan` job to have a result of `success` 31 | # when the .release-plan.json file was changed on the last commit. This 32 | # plus the fact that this action only runs on main will be enough of a guard 33 | - id: check-release 34 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 35 | 36 | publish: 37 | name: "NPM Publish" 38 | runs-on: ubuntu-latest 39 | needs: check-plan 40 | if: needs.check-plan.outputs.command == 'release' 41 | permissions: 42 | contents: write 43 | pull-requests: write 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version: 18 50 | # This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable 51 | registry-url: 'https://registry.npmjs.org' 52 | 53 | - run: npm ci 54 | - name: npm publish 55 | run: npx release-plan publish 56 | 57 | env: 58 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | -------------------------------------------------------------------------------- /src/notification.ts: -------------------------------------------------------------------------------- 1 | import Registry from './registry'; 2 | 3 | const noop = () => {}; 4 | 5 | export enum CallbackType { 6 | enter = 'enter', 7 | exit = 'exit' 8 | } 9 | 10 | export default abstract class Notifications { 11 | private registry: Registry; 12 | 13 | constructor() { 14 | this.registry = new Registry(); 15 | } 16 | 17 | /** 18 | * Adds an EventListener as a callback for an event key. 19 | * @param type 'enter' or 'exit' 20 | * @param key The key of the event 21 | * @param callback The callback function to invoke when the event occurs 22 | */ 23 | public addCallback( 24 | type: CallbackType, 25 | element: HTMLElement | Window, 26 | callback: (data?: any) => void 27 | ): void { 28 | let entry; 29 | if (type === CallbackType.enter) { 30 | entry = { [CallbackType.enter]: callback }; 31 | } else { 32 | entry = { [CallbackType.exit]: callback }; 33 | } 34 | 35 | this.registry.addElement( 36 | element, 37 | Object.assign({}, this.registry.getElement(element), entry) 38 | ); 39 | } 40 | 41 | public removeElementNotification(element: HTMLElement | Window) { 42 | this.registry.removeElement(element); 43 | } 44 | 45 | public elementNotificationExists(element: HTMLElement | Window): boolean { 46 | return Boolean(this.registry.elementExists(element)); 47 | } 48 | 49 | /** 50 | * @hidden 51 | * Executes registered callbacks for key. 52 | * @param type 53 | * @param element 54 | * @param data 55 | */ 56 | public dispatchCallback( 57 | type: CallbackType, 58 | element: HTMLElement | Window, 59 | data?: any 60 | ): void { 61 | if (type === CallbackType.enter) { 62 | const { enter = noop } = this.registry.getElement(element); 63 | enter(data); 64 | } else { 65 | // no element in WeakMap possible because element may be removed from DOM by the time we get here 66 | const found = this.registry.getElement(element); 67 | if (found && found.exit) { 68 | found.exit(data); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intersection-observer-admin", 3 | "version": "0.3.4", 4 | "description": "Intersection Observer Admin for better performance", 5 | "main": "dist/intersection-observer-admin.umd.js", 6 | "module": "dist/intersection-observer-admin.es5.js", 7 | "types": "dist/types/index.d.ts", 8 | "scripts": { 9 | "build": "tsc && tsc --outDir dist/es && rollup -c rollup.config.ts", 10 | "lint": "tslint -t codeFrame 'src/**/*.ts' 'test/index.test.ts'", 11 | "prepare": "npm run build", 12 | "prepublishOnly": "npm test && npm run lint", 13 | "preversion": "npm run lint", 14 | "start": "tsc -w & rollup -c rollup.config.ts -w", 15 | "stats": "node scripts/size-calc", 16 | "prebuild": "rimraf dist", 17 | "test": "jest" 18 | }, 19 | "jest": { 20 | "transform": { 21 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 22 | }, 23 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 24 | "moduleFileExtensions": [ 25 | "ts", 26 | "tsx", 27 | "js" 28 | ] 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+ssh://git@github.com/snewcomer/intersection-observer-admin.git" 33 | }, 34 | "keywords": [ 35 | "intersection", 36 | "observer", 37 | "infinity", 38 | "lazy", 39 | "loading" 40 | ], 41 | "author": "Scott Newcomer", 42 | "license": "ISC", 43 | "bugs": { 44 | "url": "https://github.com/snewcomer/intersection-observer-admin/issues" 45 | }, 46 | "homepage": "https://github.com/snewcomer/intersection-observer-admin#readme", 47 | "devDependencies": { 48 | "@rollup/plugin-typescript": "^2.0.1", 49 | "@types/jest": "^24.0.18", 50 | "@types/node": "^12.7.8", 51 | "gzip-size": "^5.1.1", 52 | "jest": "^24.9.0", 53 | "lodash.camelcase": "^4.3.0", 54 | "prettier": "^1.18.2", 55 | "pretty-bytes": "^5.3.0", 56 | "release-plan": "^0.9.0", 57 | "rollup": "^1.22.0", 58 | "rollup-plugin-node-resolve": "^5.2.0", 59 | "rollup-plugin-sourcemaps": "^0.4.2", 60 | "ts-jest": "^24.1.0", 61 | "tslint": "^5.20.0", 62 | "tslint-config-prettier": "^1.18.0", 63 | "tslint-eslint-rules": "^5.4.0", 64 | "tslint-plugin-prettier": "^2.0.1", 65 | "typescript": "^3.6.3" 66 | }, 67 | "dependencies": {} 68 | } 69 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import IntersectionObserverAdmin from '../src/index'; 2 | 3 | class IntersectionObserver { 4 | public root = null; 5 | public rootMargin = ''; 6 | public thresholds = []; 7 | 8 | public disconnect() { 9 | return null; 10 | } 11 | 12 | public observe() { 13 | return null; 14 | } 15 | 16 | public takeRecords() { 17 | return []; 18 | } 19 | 20 | public unobserve() { 21 | return null; 22 | } 23 | } 24 | 25 | describe('add entry', () => { 26 | it('observe exists', () => { 27 | const el = document.createElement('div'); 28 | const ioAdmin = new IntersectionObserverAdmin(); 29 | expect(ioAdmin.observe).toBeDefined(); 30 | expect(ioAdmin.unobserve).toBeDefined(); 31 | }); 32 | 33 | it('callbacks', () => { 34 | const el = document.createElement('div'); 35 | const ioAdmin = new IntersectionObserverAdmin(); 36 | expect(ioAdmin.addEnterCallback).toBeDefined(); 37 | expect(ioAdmin.addExitCallback).toBeDefined(); 38 | expect(ioAdmin.dispatchEnterCallback).toBeDefined(); 39 | expect(ioAdmin.dispatchExitCallback).toBeDefined(); 40 | }); 41 | }); 42 | 43 | describe('elements', () => { 44 | beforeEach(() => { 45 | globalThis.IntersectionObserver = IntersectionObserver; 46 | }); 47 | 48 | it('observe', () => { 49 | const el1 = document.createElement('div'); 50 | const el2 = document.createElement('div'); 51 | const el3 = document.createElement('div'); 52 | const ioAdmin = new IntersectionObserverAdmin(); 53 | 54 | ioAdmin.observe(el1); 55 | ioAdmin.observe(el2); 56 | ioAdmin.observe(el3); 57 | 58 | expect(ioAdmin.elementExists(el1)).toBeTruthy(); 59 | expect(ioAdmin.elementExists(el2)).toBeTruthy(); 60 | expect(ioAdmin.elementExists(el3)).toBeTruthy(); 61 | }); 62 | 63 | it('unobserve', () => { 64 | const el1 = document.createElement('div'); 65 | const el2 = document.createElement('div'); 66 | const el3 = document.createElement('div'); 67 | const ioAdmin = new IntersectionObserverAdmin(); 68 | 69 | ioAdmin.observe(el1); 70 | ioAdmin.observe(el2); 71 | ioAdmin.observe(el3); 72 | 73 | ioAdmin.unobserve(el1, {}); 74 | ioAdmin.unobserve(el3, {}); 75 | 76 | expect(ioAdmin.elementExists(el1)).toBeFalsy(); 77 | expect(ioAdmin.elementExists(el2)).toBeTruthy(); 78 | expect(ioAdmin.elementExists(el3)).toBeFalsy(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /.github/workflows/plan-release.yml: -------------------------------------------------------------------------------- 1 | name: Release Plan Review 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request_target: # This workflow has permissions on the repo, do NOT run code from PRs in this workflow. See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 8 | types: 9 | - labeled 10 | - unlabeled 11 | 12 | concurrency: 13 | group: plan-release # only the latest one of these should ever be running 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check-plan: 18 | name: "Check Release Plan" 19 | runs-on: ubuntu-latest 20 | outputs: 21 | command: ${{ steps.check-release.outputs.command }} 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | ref: 'master' 28 | # This will only cause the `check-plan` job to have a "command" of `release` 29 | # when the .release-plan.json file was changed on the last commit. 30 | - id: check-release 31 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 32 | 33 | prepare_release_notes: 34 | name: Prepare Release Notes 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 5 37 | needs: check-plan 38 | permissions: 39 | contents: write 40 | pull-requests: write 41 | outputs: 42 | explanation: ${{ steps.explanation.outputs.text }} 43 | # only run on push event if plan wasn't updated (don't create a release plan when we're releasing) 44 | # only run on labeled event if the PR has already been merged 45 | if: (github.event_name == 'push' && needs.check-plan.outputs.command != 'release') || (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | # We need to download lots of history so that 50 | # github-changelog can discover what's changed since the last release 51 | with: 52 | fetch-depth: 0 53 | ref: 'master' 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version: 18 57 | 58 | - run: npm ci 59 | 60 | - name: "Generate Explanation and Prep Changelogs" 61 | id: explanation 62 | run: | 63 | set +e 64 | 65 | npx release-plan prepare 2> >(tee -a release-plan-stderr.txt >&2) 66 | 67 | 68 | if [ $? -ne 0 ]; then 69 | echo 'text<> $GITHUB_OUTPUT 70 | cat release-plan-stderr.txt >> $GITHUB_OUTPUT 71 | echo 'EOF' >> $GITHUB_OUTPUT 72 | else 73 | echo 'text<> $GITHUB_OUTPUT 74 | jq .description .release-plan.json -r >> $GITHUB_OUTPUT 75 | echo 'EOF' >> $GITHUB_OUTPUT 76 | rm release-plan-stderr.txt 77 | fi 78 | env: 79 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | - uses: peter-evans/create-pull-request@v6 82 | with: 83 | commit-message: "Prepare Release using 'release-plan'" 84 | labels: "internal" 85 | branch: release-preview 86 | title: Prepare Release 87 | body: | 88 | This PR is a preview of the release that [release-plan](https://github.com/embroider-build/release-plan) has prepared. To release you should just merge this PR 👍 89 | 90 | ----------------------------------------- 91 | 92 | ${{ steps.explanation.outputs.text }} 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | intersection-observer-admin 2 | ============================================================================== 3 | ![Download count all time](https://img.shields.io/npm/dt/intersection-observer-admin.svg) 4 | [![npm version](https://badge.fury.io/js/intersection-observer-admin.svg)](http://badge.fury.io/js/intersection-observer-admin) 5 | 6 | Why use an administrator to manage all the elements on my page? 7 | ------------------------------------------------------------------------------ 8 | This library is used in [ember-in-viewport](https://github.com/DockYard/ember-in-viewport) and [ember-infinity](https://github.com/ember-infinity/ember-infinity). This library is particularly important for re-using the IntersectionObserver API. 9 | 10 | Most implementations have one Intersection Observer for each target element or so called `sentinel`. However, [IntersectionObserver.observe](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/observe) can observer multiple `sentinels`. So this library will resuse the IntersectionObserver instance for another element on the page with the same set of observer options and root element. This can dramatically improve performance for pages with lots of elements and observers. 11 | 12 | _Note: A companion library is also available for requestAnimationFrame: https://github.com/snewcomer/raf-pool_ 13 | 14 | Installation 15 | ------------------------------------------------------------------------------ 16 | 17 | ``` 18 | npm install intersection-observer-admin --save 19 | ``` 20 | 21 | Usage 22 | ------------------------------------------------------------------------------ 23 | ## API 24 | 25 | 1. element: DOM Node to observe 26 | 2. enterCallback: Function 27 | - callback function to perform logic in your own application 28 | 3. exitCallback: Function 29 | - callback function to perform when element is leaving the viewport 30 | 4. observerOptions: Object 31 | - list of options to pass to Intersection Observer constructor (https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver) 32 | 33 | ```js 34 | import IntersectionObserverAdmin from 'intersection-observer-admin'; 35 | 36 | const intersectionObserverAdmin = new IntersectionObserverAdmin(); 37 | 38 | // Add callbacks that will be called when observer detects entering and leaving viewport 39 | intersectionObserverAdmin.addEnterCallback(element, enterCallback); 40 | intersectionObserverAdmin.addExitCallback(element, exitCallback); 41 | 42 | // add an element to static administrator with window as scrollable area 43 | intersectionObserverAdmin.observe(element, { root, rootMargin: '0px 0px 100px 0px', threshold: 0 }); 44 | 45 | // add an element in a scrolling container 46 | intersectionObserverAdmin.observe(element, { root, rootMargin: '0px 0px 100px 0px', threshold: 0 }); 47 | 48 | // Use in cleanup lifecycle hooks (if applicable) from the element being observed 49 | intersectionObserverAdmin.unobserve(element, observerOptions); 50 | 51 | // Use in cleanup lifecycle hooks of your application as a whole 52 | // This will remove the in memory data store holding onto all of the observers 53 | intersectionObserverAdmin.destroy(); 54 | ``` 55 | 56 | [IntersectionObserver's Browser Support](https://platform-status.mozilla.org/) 57 | ------------------------------------------------------------------------------ 58 | ### Out of the box 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
Chrome51 [1]
Firefox (Gecko)55 [2]
MS Edge15
Internet ExplorerNot supported
Opera [1]38
Safari12.1
Chrome for Android59
Android Browser56
Opera Mobile37
98 | 99 | * [1] [Reportedly available](https://www.chromestatus.com/features/5695342691483648), it didn't trigger the events on initial load and lacks `isIntersecting` until later versions. 100 | * [2] This feature was implemented in Gecko 53.0 (Firefox 53.0 / Thunderbird 53.0 / SeaMonkey 2.50) behind the preference `dom.IntersectionObserver.enabled`. 101 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "moduleResolution": "node", 5 | "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 6 | "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "lib": [ 8 | "es5", 9 | "es2015", 10 | "dom" 11 | ], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | "declarationDir": "dist/types", 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | "stripInternal": true, 19 | "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | "outDir": "dist/es", /* Redirect output structure to the directory. */ 22 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "composite": true, /* Enable project compilation */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | /* Strict Type-Checking Options */ 30 | "strict": true, /* Enable all strict type-checking options. */ 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "include": [ 62 | "src/**/*" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Notifications, { CallbackType } from './notification'; 2 | import Registry from './registry'; 3 | 4 | export interface IOptions { 5 | root?: HTMLElement; 6 | rootMargin?: string; 7 | threshold?: number | number[]; 8 | [key: string]: any; 9 | } 10 | 11 | type StateForRoot = { 12 | elements: HTMLElement[]; 13 | options: IOptions; 14 | intersectionObserver: any; 15 | }; 16 | 17 | type PotentialRootEntry = { 18 | [stringifiedOptions: string]: StateForRoot; 19 | }; 20 | 21 | export default class IntersectionObserverAdmin extends Notifications { 22 | private elementRegistry: Registry; 23 | 24 | constructor() { 25 | super(); 26 | this.elementRegistry = new Registry(); 27 | } 28 | 29 | /** 30 | * Adds element to observe via IntersectionObserver and stores element + relevant callbacks and observer options in static 31 | * administrator for lookup in the future 32 | * 33 | * @method observe 34 | * @param {HTMLElement | Window} element 35 | * @param {Object} options 36 | * @public 37 | */ 38 | public observe(element: HTMLElement, options: IOptions = {}): void { 39 | if (!element) { 40 | return; 41 | } 42 | 43 | this.elementRegistry.addElement(element, { ...options }); 44 | 45 | this.setupObserver(element, { ...options }); 46 | } 47 | 48 | /** 49 | * Unobserve target element and remove element from static admin 50 | * 51 | * @method unobserve 52 | * @param {HTMLElement|Window} target 53 | * @param {Object} options 54 | * @public 55 | */ 56 | public unobserve(target: HTMLElement, options: IOptions): void { 57 | const matchingRootEntry: 58 | | StateForRoot 59 | | undefined = this.findMatchingRootEntry(options); 60 | 61 | if (matchingRootEntry) { 62 | this.clearRootEntry(target, matchingRootEntry); 63 | } else { 64 | this.removeElement(target); 65 | this.clearDefaultRoot(target); 66 | } 67 | } 68 | 69 | /** 70 | * register event to handle when intersection observer detects enter 71 | * 72 | * @method addEnterCallback 73 | * @public 74 | */ 75 | public addEnterCallback( 76 | element: HTMLElement | Window, 77 | callback: (data?: any) => void 78 | ) { 79 | this.addCallback(CallbackType.enter, element, callback); 80 | } 81 | 82 | /** 83 | * register event to handle when intersection observer detects exit 84 | * 85 | * @method addExitCallback 86 | * @public 87 | */ 88 | public addExitCallback( 89 | element: HTMLElement | Window, 90 | callback: (data?: any) => void 91 | ) { 92 | this.addCallback(CallbackType.exit, element, callback); 93 | } 94 | 95 | /** 96 | * retrieve registered callback and call with data 97 | * 98 | * @method dispatchEnterCallback 99 | * @public 100 | */ 101 | public dispatchEnterCallback(element: HTMLElement | Window, entry: any) { 102 | this.dispatchCallback(CallbackType.enter, element, entry); 103 | } 104 | 105 | /** 106 | * retrieve registered callback and call with data on exit 107 | * 108 | * @method dispatchExitCallback 109 | * @public 110 | */ 111 | public dispatchExitCallback(element: HTMLElement | Window, entry: any) { 112 | this.dispatchCallback(CallbackType.exit, element, entry); 113 | } 114 | 115 | /** 116 | * cleanup data structures and unobserve elements 117 | * 118 | * @method destroy 119 | * @public 120 | */ 121 | public destroy(): void { 122 | this.elementRegistry.destroyRegistry(); 123 | } 124 | 125 | /** 126 | * cleanup removes provided elements from both registries 127 | * 128 | * @method removeElement 129 | * @public 130 | * 131 | */ 132 | public removeElement(element: HTMLElement | Window): void { 133 | this.removeElementNotification(element); 134 | this.elementRegistry.removeElement(element); 135 | } 136 | 137 | /** 138 | * checks whether element exists in either registry 139 | * 140 | * @method elementExists 141 | * @public 142 | * 143 | */ 144 | public elementExists(element: HTMLElement | Window): boolean { 145 | return Boolean( 146 | this.elementNotificationExists(element) || 147 | this.elementRegistry.elementExists(element) 148 | ); 149 | } 150 | 151 | /** 152 | * use function composition to curry options 153 | * 154 | * @method setupOnIntersection 155 | * @param {Object} options 156 | */ 157 | protected setupOnIntersection(options: IOptions): Function { 158 | return (ioEntries: any) => { 159 | return this.onIntersection(options, ioEntries); 160 | }; 161 | } 162 | 163 | protected setupObserver(element: HTMLElement, options: IOptions): void { 164 | const { root = window } = options; 165 | 166 | // First - find shared root element (window or target HTMLElement) 167 | // this root is responsible for coordinating it's set of elements 168 | const potentialRootMatch: 169 | | PotentialRootEntry 170 | | null 171 | | undefined = this.findRootFromRegistry(root); 172 | 173 | // Second - if there is a matching root, see if an existing entry with the same options 174 | // regardless of sort order. This is a bit of work 175 | let matchingEntryForRoot; 176 | if (potentialRootMatch) { 177 | matchingEntryForRoot = this.determineMatchingElements( 178 | options, 179 | potentialRootMatch 180 | ); 181 | } 182 | 183 | // next add found entry to elements and call observer if applicable 184 | if (matchingEntryForRoot) { 185 | const { elements, intersectionObserver } = matchingEntryForRoot; 186 | elements.push(element); 187 | if (intersectionObserver) { 188 | intersectionObserver.observe(element); 189 | } 190 | } else { 191 | // otherwise start observing this element if applicable 192 | // watcher is an instance that has an observe method 193 | const intersectionObserver = this.newObserver(element, options); 194 | 195 | const observerEntry: StateForRoot = { 196 | elements: [element], 197 | intersectionObserver, 198 | options 199 | }; 200 | 201 | // and add entry to WeakMap under a root element 202 | // with watcher so we can use it later on 203 | const stringifiedOptions: string = this.stringifyOptions(options); 204 | if (potentialRootMatch) { 205 | // if share same root and need to add new entry to root match 206 | // not functional but :shrug 207 | potentialRootMatch[stringifiedOptions] = observerEntry; 208 | } else if (!this.elementRegistry.elementExists(root)) { 209 | // no root exists, so add to WeakMap 210 | this.elementRegistry.addElement(root, { 211 | [stringifiedOptions]: observerEntry 212 | }); 213 | } 214 | } 215 | } 216 | 217 | private newObserver( 218 | element: HTMLElement, 219 | options: IOptions 220 | ): IntersectionObserver { 221 | // No matching entry for root in static admin, thus create new IntersectionObserver instance 222 | const { root, rootMargin, threshold } = options; 223 | 224 | const newIO = new IntersectionObserver( 225 | this.setupOnIntersection(options).bind(this), 226 | { root, rootMargin, threshold } 227 | ); 228 | newIO.observe(element); 229 | 230 | return newIO; 231 | } 232 | 233 | /** 234 | * IntersectionObserver callback when element is intersecting viewport 235 | * either when `isIntersecting` changes or `intersectionRadio` crosses on of the 236 | * configured `threshold`s. 237 | * Exit callback occurs eagerly (when element is initially out of scope) 238 | * See https://stackoverflow.com/questions/53214116/intersectionobserver-callback-firing-immediately-on-page-load/53385264#53385264 239 | * 240 | * @method onIntersection 241 | * @param {Object} options 242 | * @param {Array} ioEntries 243 | * @private 244 | */ 245 | private onIntersection(options: IOptions, ioEntries: Array): void { 246 | ioEntries.forEach(entry => { 247 | const { isIntersecting, intersectionRatio } = entry; 248 | let threshold = options.threshold || 0; 249 | if (Array.isArray(threshold)) { 250 | threshold = threshold[threshold.length - 1]; 251 | } 252 | 253 | // then find entry's callback in static administration 254 | const matchingRootEntry: 255 | | StateForRoot 256 | | undefined = this.findMatchingRootEntry(options); 257 | 258 | // first determine if entry intersecting 259 | if (isIntersecting || intersectionRatio > threshold) { 260 | if (matchingRootEntry) { 261 | matchingRootEntry.elements.some((element: HTMLElement) => { 262 | if (element && element === entry.target) { 263 | this.dispatchEnterCallback(element, entry); 264 | return true; 265 | } 266 | return false; 267 | }); 268 | } 269 | } else { 270 | if (matchingRootEntry) { 271 | matchingRootEntry.elements.some((element: HTMLElement) => { 272 | if (element && element === entry.target) { 273 | this.dispatchExitCallback(element, entry); 274 | return true; 275 | } 276 | return false; 277 | }); 278 | } 279 | } 280 | }); 281 | } 282 | 283 | /** 284 | * { root: { stringifiedOptions: { observer, elements: []...] } } 285 | * @method findRootFromRegistry 286 | * @param {HTMLElement|Window} root 287 | * @private 288 | * @return {Object} of elements that share same root 289 | */ 290 | private findRootFromRegistry( 291 | root: HTMLElement | Window 292 | ): PotentialRootEntry | null | undefined { 293 | if (this.elementRegistry) { 294 | return this.elementRegistry.getElement(root); 295 | } 296 | } 297 | 298 | /** 299 | * We don't care about options key order because we already added 300 | * to the static administrator 301 | * 302 | * @method findMatchingRootEntry 303 | * @param {Object} options 304 | * @return {Object} entry with elements and other options 305 | */ 306 | private findMatchingRootEntry(options: IOptions): StateForRoot | undefined { 307 | const { root = window } = options; 308 | const matchingRoot: 309 | | PotentialRootEntry 310 | | null 311 | | undefined = this.findRootFromRegistry(root); 312 | 313 | if (matchingRoot) { 314 | const stringifiedOptions: string = this.stringifyOptions(options); 315 | return matchingRoot[stringifiedOptions]; 316 | } 317 | } 318 | 319 | /** 320 | * Determine if existing elements for a given root based on passed in options 321 | * regardless of sort order of keys 322 | * 323 | * @method determineMatchingElements 324 | * @param {Object} options 325 | * @param {Object} potentialRootMatch e.g. { stringifiedOptions: { elements: [], ... }, stringifiedOptions: { elements: [], ... }} 326 | * @private 327 | * @return {Object} containing array of elements and other meta 328 | */ 329 | private determineMatchingElements( 330 | options: IOptions, 331 | potentialRootMatch: PotentialRootEntry 332 | ): StateForRoot | undefined { 333 | const matchingStringifiedOptions = Object.keys(potentialRootMatch).filter( 334 | key => { 335 | const { options: comparableOptions } = potentialRootMatch[key]; 336 | return this.areOptionsSame(options, comparableOptions); 337 | } 338 | )[0]; 339 | 340 | return potentialRootMatch[matchingStringifiedOptions]; 341 | } 342 | 343 | /** 344 | * recursive method to test primitive string, number, null, etc and complex 345 | * object equality. 346 | * 347 | * @method areOptionsSame 348 | * @param {any} a 349 | * @param {any} b 350 | * @private 351 | * @return {boolean} 352 | */ 353 | private areOptionsSame(a: IOptions | any, b: IOptions | any): boolean { 354 | if (a === b) { 355 | return true; 356 | } 357 | 358 | // simple comparison 359 | const type1 = Object.prototype.toString.call(a); 360 | const type2 = Object.prototype.toString.call(b); 361 | if (type1 !== type2) { 362 | return false; 363 | } else if (type1 !== '[object Object]' && type2 !== '[object Object]') { 364 | return a === b; 365 | } 366 | 367 | if (a && b && typeof a === 'object' && typeof b === 'object') { 368 | // complex comparison for only type of [object Object] 369 | for (const key in a) { 370 | if (Object.prototype.hasOwnProperty.call(a, key)) { 371 | // recursion to check nested 372 | if (this.areOptionsSame(a[key], b[key]) === false) { 373 | return false; 374 | } 375 | } 376 | } 377 | } 378 | 379 | // if nothing failed 380 | return true; 381 | } 382 | 383 | /** 384 | * Stringify options for use as a key. 385 | * Excludes options.root so that the resulting key is stable 386 | * 387 | * @param {Object} options 388 | * @private 389 | * @return {String} 390 | */ 391 | private stringifyOptions(options: IOptions): string { 392 | const { root } = options; 393 | 394 | const replacer = (key: string, value: string): string => { 395 | if (key === 'root' && root) { 396 | const classList = Array.prototype.slice.call(root.classList); 397 | 398 | const classToken = classList.reduce((acc, item) => { 399 | return (acc += item); 400 | }, ''); 401 | 402 | const id: string = root.id; 403 | 404 | return `${id}-${classToken}`; 405 | } 406 | 407 | return value; 408 | }; 409 | 410 | return JSON.stringify(options, replacer); 411 | } 412 | 413 | private clearRootEntry(target: HTMLElement, rootState: StateForRoot) { 414 | const { intersectionObserver } = rootState; 415 | intersectionObserver.unobserve(target); 416 | 417 | if (rootState.elements) { 418 | rootState.elements = rootState.elements.filter( 419 | (el: any) => el !== target 420 | ); 421 | } 422 | this.removeElement(target); 423 | this.clearDefaultRoot(target); 424 | } 425 | 426 | private clearDefaultRoot(target: HTMLElement) { 427 | const windowRoot = this.elementRegistry.getElement(window); 428 | if (windowRoot && windowRoot.elements) { 429 | windowRoot.elements = windowRoot.elements.filter( 430 | (el: any) => el !== target 431 | ); 432 | } 433 | } 434 | } 435 | --------------------------------------------------------------------------------