From b6aab5f363f3f6c6f0b626a714a7bb7e6310043e Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Thu, 9 Apr 2026 00:37:15 +0530 Subject: [PATCH 01/11] chore: upgrade to Rollup 4, TypeScript 5, drop throttle-debounce Replace rollup.config.js with rollup.config.mjs, swap rollup v1 plugins for @rollup/plugin-node-resolve and @rollup/plugin-typescript, and remove the throttle-debounce runtime dependency. --- package.json | 13 +- rollup.config.js | 26 ---- rollup.config.mjs | 35 ++++++ tsconfig.json | 30 ++--- yarn.lock | 315 ++++++++++++++++++++++++++++++---------------- 5 files changed, 258 insertions(+), 161 deletions(-) delete mode 100644 rollup.config.js create mode 100644 rollup.config.mjs diff --git a/package.json b/package.json index 9ceebbf..d632062 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,12 @@ "@storybook/addon-essentials": "^7.6.0", "@storybook/react": "^7.6.0", "@storybook/react-webpack5": "^7.6.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-typescript": "^11.0.0", "@testing-library/react": "^12.1.5", "@types/jest": "^29.5.14", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/throttle-debounce": "^2.1.0", "@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/parser": "^8.50.1", "babel-loader": "^8.0.6", @@ -75,17 +76,13 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "rimraf": "^3.0.0", - "rollup": "^1.26.3", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-typescript2": "^0.25.2", + "rollup": "^4.0.0", "size-limit": "^12.0.1", "storybook": "^7.6.0", "ts-jest": "^29.4.6", - "typescript": "^4.9.0" - }, - "dependencies": { - "throttle-debounce": "^2.1.0" + "typescript": "^5.4.0" }, + "dependencies": {}, "size-limit": [ { "path": "dist/index.es.js", diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index de54421..0000000 --- a/rollup.config.js +++ /dev/null @@ -1,26 +0,0 @@ -import resolve from 'rollup-plugin-node-resolve'; -import typescript from 'rollup-plugin-typescript2'; -import pkg from './package.json'; -export default { - input: './src/index.tsx', - output: [ - { - file: pkg.main, - format: 'cjs', - sourcemap: true, - }, - { - file: pkg.module, - format: 'es', - sourcemap: true, - }, - { - file: pkg.unpkg, - format: 'iife', - sourcemap: true, - name: 'InfiniteScroll', - }, - ], - external: [...Object.keys(pkg.peerDependencies || {}), 'react/jsx-runtime'], - plugins: [resolve(), typescript({ useTsconfigDeclarationDir: true })], -}; diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 0000000..ee4cd91 --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,35 @@ +import resolve from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const pkg = require('./package.json'); + +export default { + input: './src/index.tsx', + output: [ + { + file: pkg.main, + format: 'cjs', + sourcemap: true, + }, + { + file: pkg.module, + format: 'es', + sourcemap: true, + }, + { + file: pkg.unpkg, + format: 'iife', + sourcemap: true, + name: 'InfiniteScroll', + globals: { + react: 'React', + 'react/jsx-runtime': 'ReactJSXRuntime', + 'react-dom': 'ReactDOM', + }, + }, + ], + external: [...Object.keys(pkg.peerDependencies || {}), 'react/jsx-runtime'], + plugins: [resolve(), typescript({ tsconfig: './tsconfig.json' })], +}; diff --git a/tsconfig.json b/tsconfig.json index 50b7250..565e354 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,23 +6,23 @@ "lib": [ "ES2019", "DOM" - ], /* Specify library files to be included in the compilation. */ + ] /* Specify library files to be included in the compilation. */, // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, "declaration": true /* Generates corresponding '.d.ts' file. */, "declarationDir": "./dist", - "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": true, /* Generates corresponding '.map' file. */ + "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, + "sourceMap": true /* Generates corresponding '.map' file. */, // "outFile": "./", /* Concatenate and emit output to single file. */ - "outDir": "./dist", /* Redirect output structure to the directory. */ + "outDir": "./dist" /* Redirect output structure to the directory. */, // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ - "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, @@ -43,11 +43,13 @@ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ + "types": [ + "jest" + ] /* Type declaration files to be included in compilation. */, // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "forceConsistentCasingInFileNames": true // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ @@ -58,14 +60,6 @@ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ }, - "include": [ - "src/**/*", - "lint-staged.config.js", - "jest.config.js", - "rollup.config.js" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "include": ["src/**/*", "lint-staged.config.js", "jest.config.js"], + "exclude": ["node_modules", "dist"] +} diff --git a/yarn.lock b/yarn.lock index e838d4a..5953e1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2042,6 +2042,159 @@ dependencies: "@babel/runtime" "^7.13.10" +"@rollup/plugin-node-resolve@^15.0.0": + version "15.3.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz#66008953c2524be786aa319d49e32f2128296a78" + integrity sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA== + dependencies: + "@rollup/pluginutils" "^5.0.1" + "@types/resolve" "1.20.2" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.22.1" + +"@rollup/plugin-typescript@^11.0.0": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz#724237d5ec12609ec01429f619d2a3e7d4d1b22b" + integrity sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA== + dependencies: + "@rollup/pluginutils" "^5.1.0" + resolve "^1.22.1" + +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-android-arm-eabi@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz#043f145716234529052ef9e1ce1d847ffbe9e674" + integrity sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA== + +"@rollup/rollup-android-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz#023e1bd146e7519087dfd9e8b29e4cf9f8ecd35c" + integrity sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA== + +"@rollup/rollup-darwin-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz#55ccb5487c02419954c57a7a80602885d616e1ee" + integrity sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw== + +"@rollup/rollup-darwin-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz#254b65404b14488c83225e88b8819376ad71a784" + integrity sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew== + +"@rollup/rollup-freebsd-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz#6377ff38c052c76fcaffb7b2728d3172fe676fe6" + integrity sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w== + +"@rollup/rollup-freebsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz#ba3902309d088eaf7139b916f09b7140b28b406d" + integrity sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g== + +"@rollup/rollup-linux-arm-gnueabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz#e011b9a14638267e53b446286e838dbdaf53f167" + integrity sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g== + +"@rollup/rollup-linux-arm-musleabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz#0bce9ce9a009490abd28fd922dd97ed521311afe" + integrity sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg== + +"@rollup/rollup-linux-arm64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz#6f6cfbbf324fbb4ceff213abdf7f322fd45d25ff" + integrity sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ== + +"@rollup/rollup-linux-arm64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz#f7cb3eecaea9c151ef77342af05f38ae924bf795" + integrity sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA== + +"@rollup/rollup-linux-loong64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz#499bfac6bb669fd88bb664357bf6be996a28b92f" + integrity sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ== + +"@rollup/rollup-linux-loong64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz#127dfac08764764396bbe04453c545d38a3ab518" + integrity sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw== + +"@rollup/rollup-linux-ppc64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz#6a72f4d95852aac18326c5bf708393e8f3a41b70" + integrity sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw== + +"@rollup/rollup-linux-ppc64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz#ba8674666b00d6f9066cb9a5771a8430c34d2de6" + integrity sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg== + +"@rollup/rollup-linux-riscv64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz#17cc38b2a71e302547cad29bcf78d0db2618c922" + integrity sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg== + +"@rollup/rollup-linux-riscv64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz#e36a41e2d8bd247331bd5cfc13b8c951d33454a2" + integrity sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg== + +"@rollup/rollup-linux-s390x-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz#1687265f1f4bdea0726c761a58c2db9933609d68" + integrity sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ== + +"@rollup/rollup-linux-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz#56a6a0d9076f2a05a976031493b24a20ddcc0e77" + integrity sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg== + +"@rollup/rollup-linux-x64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz#bc240ebb5b9fd8d41ca8a80cb458452e8c187e0f" + integrity sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w== + +"@rollup/rollup-openbsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz#6f80d48a006c4b2ffa7724e95a3e33f6975872af" + integrity sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw== + +"@rollup/rollup-openharmony-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz#8f6db6f70d0a48abd833b263cd6dd3e7199c4c0e" + integrity sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA== + +"@rollup/rollup-win32-arm64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz#b68989bfa815d0b3d4e302ecd90bda744438b177" + integrity sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g== + +"@rollup/rollup-win32-ia32-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz#c098e45338c50f22f1b288476354f025b746285b" + integrity sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg== + +"@rollup/rollup-win32-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz#2c9e15be155b79d05999953b1737b2903842e903" + integrity sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg== + +"@rollup/rollup-win32-x64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz#23b860113e9f87eea015d1fa3a4240a52b42fcd4" + integrity sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz" @@ -2944,7 +3097,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.8": +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.8": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -3130,12 +3283,10 @@ "@types/scheduler" "^0.16" csstype "^3.2.2" -"@types/resolve@0.0.8": - version "0.0.8" - resolved "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz" - integrity sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ== - dependencies: - "@types/node" "*" +"@types/resolve@1.20.2": + version "1.20.2" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" + integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== "@types/resolve@^1.20.2": version "1.20.6" @@ -3181,11 +3332,6 @@ resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== -"@types/throttle-debounce@^2.1.0": - version "2.1.0" - resolved "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz" - integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ== - "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz" @@ -3511,7 +3657,7 @@ acorn-walk@^8.0.2: dependencies: acorn "^8.11.0" -acorn@^7.1.0, acorn@^7.4.1: +acorn@^7.4.1: version "7.4.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== @@ -4076,11 +4222,6 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" -builtin-modules@^3.1.0: - version "3.3.0" - resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz" - integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== - bytes-iec@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/bytes-iec/-/bytes-iec-3.1.1.tgz#94cd36bf95c2c22a82002c247df8772d1d591083" @@ -5357,10 +5498,10 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -estree-walker@^0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz" - integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== esutils@^2.0.2: version "2.0.3" @@ -5733,15 +5874,6 @@ fs-extra@11.1.1: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@8.1.0: - version "8.1.0" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -7170,13 +7302,6 @@ json5@^2.1.2, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz" - integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== - optionalDependencies: - graceful-fs "^4.1.6" - jsonfile@^6.0.1: version "6.2.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.0.tgz#7c265bd1b65de6977478300087c99f1c84383f62" @@ -8069,7 +8194,7 @@ path-key@^4.0.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== -path-parse@^1.0.6, path-parse@^1.0.7: +path-parse@^1.0.7: version "1.0.7" resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -8121,6 +8246,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.0, picomatc resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" @@ -8731,14 +8861,7 @@ resolve.exports@^2.0.0: resolved "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz" integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== -resolve@1.12.0: - version "1.12.0" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz" - integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== - dependencies: - path-parse "^1.0.6" - -resolve@^1.10.0, resolve@^1.11.1, resolve@^1.20.0, resolve@^1.22.1, resolve@^1.22.10: +resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.1, resolve@^1.22.10: version "1.22.11" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz" integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== @@ -8803,50 +8926,39 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" -rollup-plugin-node-resolve@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz" - integrity sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw== - dependencies: - "@types/resolve" "0.0.8" - builtin-modules "^3.1.0" - is-module "^1.0.0" - resolve "^1.11.1" - rollup-pluginutils "^2.8.1" - -rollup-plugin-typescript2@^0.25.2: - version "0.25.3" - resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.25.3.tgz" - integrity sha512-ADkSaidKBovJmf5VBnZBZe+WzaZwofuvYdzGAKTN/J4hN7QJCFYAq7IrH9caxlru6T5qhX41PNFS1S4HqhsGQg== - dependencies: - find-cache-dir "^3.0.0" - fs-extra "8.1.0" - resolve "1.12.0" - rollup-pluginutils "2.8.1" - tslib "1.10.0" - -rollup-pluginutils@2.8.1: - version "2.8.1" - resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz" - integrity sha512-J5oAoysWar6GuZo0s+3bZ6sVZAC0pfqKz68De7ZgDi5z63jOVZn1uJL/+z1jeKHNbGII8kAyHF5q8LnxSX5lQg== - dependencies: - estree-walker "^0.6.1" - -rollup-pluginutils@^2.8.1: - version "2.8.2" - resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz" - integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== - dependencies: - estree-walker "^0.6.1" - -rollup@^1.26.3: - version "1.32.1" - resolved "https://registry.npmjs.org/rollup/-/rollup-1.32.1.tgz" - integrity sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A== +rollup@^4.0.0: + version "4.60.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.1.tgz#b4aa2bcb3a5e1437b5fad40d43fe42d4bde7a42d" + integrity sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w== dependencies: - "@types/estree" "*" - "@types/node" "*" - acorn "^7.1.0" + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.60.1" + "@rollup/rollup-android-arm64" "4.60.1" + "@rollup/rollup-darwin-arm64" "4.60.1" + "@rollup/rollup-darwin-x64" "4.60.1" + "@rollup/rollup-freebsd-arm64" "4.60.1" + "@rollup/rollup-freebsd-x64" "4.60.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.1" + "@rollup/rollup-linux-arm-musleabihf" "4.60.1" + "@rollup/rollup-linux-arm64-gnu" "4.60.1" + "@rollup/rollup-linux-arm64-musl" "4.60.1" + "@rollup/rollup-linux-loong64-gnu" "4.60.1" + "@rollup/rollup-linux-loong64-musl" "4.60.1" + "@rollup/rollup-linux-ppc64-gnu" "4.60.1" + "@rollup/rollup-linux-ppc64-musl" "4.60.1" + "@rollup/rollup-linux-riscv64-gnu" "4.60.1" + "@rollup/rollup-linux-riscv64-musl" "4.60.1" + "@rollup/rollup-linux-s390x-gnu" "4.60.1" + "@rollup/rollup-linux-x64-gnu" "4.60.1" + "@rollup/rollup-linux-x64-musl" "4.60.1" + "@rollup/rollup-openbsd-x64" "4.60.1" + "@rollup/rollup-openharmony-arm64" "4.60.1" + "@rollup/rollup-win32-arm64-msvc" "4.60.1" + "@rollup/rollup-win32-ia32-msvc" "4.60.1" + "@rollup/rollup-win32-x64-gnu" "4.60.1" + "@rollup/rollup-win32-x64-msvc" "4.60.1" + fsevents "~2.3.2" run-parallel@^1.1.9: version "1.2.0" @@ -9555,11 +9667,6 @@ text-table@^0.2.0: resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -throttle-debounce@^2.1.0: - version "2.3.0" - resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz" - integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== - through2@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" @@ -9655,11 +9762,6 @@ ts-jest@^29.4.6: type-fest "^4.41.0" yargs-parser "^21.1.1" -tslib@1.10.0: - version "1.10.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz" - integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== - tslib@^1.13.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -9775,10 +9877,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@^4.9.0: - version "4.9.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.4.0: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== ufo@^1.5.4, ufo@^1.6.1: version "1.6.1" @@ -9862,11 +9964,6 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - universalify@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz" From ee6fd3f610317bf2d8415d12fe252c635a98c8ac Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Thu, 9 Apr 2026 00:39:36 +0530 Subject: [PATCH 02/11] feat: rewrite core as function component with IntersectionObserver sentinel Replace class component + scroll listeners with a function component that uses an IntersectionObserver on an invisible sentinel div. Adds buildRootMargin utility to convert scrollThreshold into a CSS rootMargin string. Drops throttle-debounce usage entirely. --- src/index.tsx | 616 ++++++++++++++++------------------- src/utils/buildRootMargin.ts | 38 +++ 2 files changed, 326 insertions(+), 328 deletions(-) create mode 100644 src/utils/buildRootMargin.ts diff --git a/src/index.tsx b/src/index.tsx index 5c5cce9..3a286da 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,15 @@ -import { Component, ReactNode, CSSProperties } from 'react'; -import { throttle } from 'throttle-debounce'; -import { ThresholdUnits, parseThreshold } from './utils/threshold'; +import { + useState, + useEffect, + useRef, + useCallback, + ReactNode, + CSSProperties, +} from 'react'; +import { buildRootMargin } from './utils/buildRootMargin'; type Fn = () => any; + export interface Props { next: Fn; hasMore: boolean; @@ -12,7 +19,7 @@ export interface Props { endMessage?: ReactNode; style?: CSSProperties; height?: number | string; - scrollableTarget?: ReactNode; + scrollableTarget?: HTMLElement | string | null; hasChildren?: boolean; inverse?: boolean; pullDownToRefresh?: boolean; @@ -26,371 +33,324 @@ export interface Props { className?: string; } -interface State { - showLoader: boolean; - pullToRefreshThresholdBreached: boolean; - prevDataLength: number | undefined; -} - -export default class InfiniteScroll extends Component { - constructor(props: Props) { - super(props); - - this.state = { - showLoader: false, - pullToRefreshThresholdBreached: false, - prevDataLength: props.dataLength, - }; +export default function InfiniteScroll({ + next, + hasMore, + children, + loader, + scrollThreshold = 0.8, + endMessage, + style, + height, + scrollableTarget, + hasChildren, + inverse = false, + pullDownToRefresh = false, + pullDownToRefreshContent, + releaseToRefreshContent, + pullDownToRefreshThreshold = 100, + refreshFunction, + onScroll, + dataLength, + initialScrollY, + className = '', +}: Props) { + const [showLoader, setShowLoader] = useState(false); + const [pullToRefreshThresholdBreached, setPullToRefreshThresholdBreached] = + useState(false); + // State drives the JSX re-render when height is measured; ref is read by handlers + const [maxPullDownDistance, setMaxPullDownDistance] = useState(0); + + const infScrollRef = useRef(null); + const sentinelRef = useRef(null); + const pullDownRef = useRef(null); + + // --- Stable callback refs --- + // Assigned synchronously every render so effects and event handlers always call + // the latest version without adding the functions to any effect dependency array. + // This prevents the IO observer and PTR listeners from being recreated every time + // consumers pass inline functions (the most common real-world usage). + const nextRef = useRef(next); + nextRef.current = next; + + const refreshFunctionRef = useRef(refreshFunction); + refreshFunctionRef.current = refreshFunction; + + // Ref for pullDownToRefreshThreshold so onMove always reads the current value + // without needing it in the PTR effect deps + const pullThresholdRef = useRef(pullDownToRefreshThreshold); + pullThresholdRef.current = pullDownToRefreshThreshold; + + // --- Mutable refs — never trigger re-renders --- + const actionTriggeredRef = useRef(false); + const draggingRef = useRef(false); + const startYRef = useRef(0); + const currentYRef = useRef(0); + const maxPullDownDistanceRef = useRef(0); // kept in sync with maxPullDownDistance state + + // Resolve the custom scrollable element; stable as long as scrollableTarget doesn't change + const getScrollableNode = useCallback((): HTMLElement | null => { + if (scrollableTarget instanceof HTMLElement) return scrollableTarget; + if (typeof scrollableTarget === 'string') { + return document.getElementById(scrollableTarget); + } + if (scrollableTarget === null) { + console.warn( + `You are trying to pass scrollableTarget but it is null. This might + happen because the element may not have been added to DOM yet. + See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. + ` + ); + } + return null; + }, [scrollableTarget]); - this.throttledOnScrollListener = throttle(150, this.onScrollListener).bind( - this - ); - this.onStart = this.onStart.bind(this); - this.onMove = this.onMove.bind(this); - this.onEnd = this.onEnd.bind(this); - } - - private throttledOnScrollListener: (e: MouseEvent) => void; - private _scrollableNode: HTMLElement | undefined | null; - private el: HTMLElement | undefined | (Window & typeof globalThis); - private _infScroll: HTMLDivElement | undefined; - private lastScrollTop = 0; - private actionTriggered = false; - private _pullDown: HTMLDivElement | undefined; - - // variables to keep track of pull down behaviour - private startY = 0; - private currentY = 0; - private dragging = false; - - // will be populated in componentDidMount - // based on the height of the pull down element - private maxPullDownDistance = 0; - - componentDidMount() { - if (typeof this.props.dataLength === 'undefined') { + // Effect 1 — one-time validation and initialScrollY + useEffect(() => { + if (typeof dataLength === 'undefined') { throw new Error( `mandatory prop "dataLength" is missing. The prop is needed` + ` when loading more content. Check README.md for usage` ); } - this._scrollableNode = this.getScrollableTarget(); - this.el = this.props.height - ? this._infScroll - : this._scrollableNode || window; - - if (this.el) { - this.el.addEventListener( - 'scroll', - this.throttledOnScrollListener as EventListenerOrEventListenerObject - ); - } - - if ( - typeof this.props.initialScrollY === 'number' && - this.el && - this.el instanceof HTMLElement && - this.el.scrollHeight > this.props.initialScrollY - ) { - this.el.scrollTo(0, this.props.initialScrollY); - } - - if (this.props.pullDownToRefresh && this.el) { - this.el.addEventListener('touchstart', this.onStart); - this.el.addEventListener('touchmove', this.onMove); - this.el.addEventListener('touchend', this.onEnd); - - this.el.addEventListener('mousedown', this.onStart); - this.el.addEventListener('mousemove', this.onMove); - this.el.addEventListener('mouseup', this.onEnd); - - // get BCR of pullDown element to position it above - this.maxPullDownDistance = - (this._pullDown && - this._pullDown.firstChild && - (this._pullDown.firstChild as HTMLDivElement).getBoundingClientRect() - .height) || - 0; - this.forceUpdate(); - - if (typeof this.props.refreshFunction !== 'function') { - throw new Error( - `Mandatory prop "refreshFunction" missing. + if (pullDownToRefresh && typeof refreshFunction !== 'function') { + throw new Error( + `Mandatory prop "refreshFunction" missing. Pull Down To Refresh functionality will not work as expected. Check README.md for usage'` - ); - } - } - } - - componentWillUnmount() { - if (this.el) { - this.el.removeEventListener( - 'scroll', - this.throttledOnScrollListener as EventListenerOrEventListenerObject ); - - if (this.props.pullDownToRefresh) { - this.el.removeEventListener('touchstart', this.onStart); - this.el.removeEventListener('touchmove', this.onMove); - this.el.removeEventListener('touchend', this.onEnd); - - this.el.removeEventListener('mousedown', this.onStart); - this.el.removeEventListener('mousemove', this.onMove); - this.el.removeEventListener('mouseup', this.onEnd); - } } - } - - componentDidUpdate(prevProps: Props) { - // do nothing when dataLength is unchanged - if (this.props.dataLength === prevProps.dataLength) return; - - this.actionTriggered = false; - - // update state when new data was sent in - this.setState({ - showLoader: false, - }); - } - - static getDerivedStateFromProps(nextProps: Props, prevState: State) { - const dataLengthChanged = nextProps.dataLength !== prevState.prevDataLength; - // reset when data changes - if (dataLengthChanged) { - return { - ...prevState, - prevDataLength: nextProps.dataLength, - }; - } - return null; - } - - getScrollableTarget = () => { - if (this.props.scrollableTarget instanceof HTMLElement) - return this.props.scrollableTarget; - if (typeof this.props.scrollableTarget === 'string') { - return document.getElementById(this.props.scrollableTarget); - } - if (this.props.scrollableTarget === null) { - console.warn(`You are trying to pass scrollableTarget but it is null. This might - happen because the element may not have been added to DOM yet. - See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. - `); + if (typeof initialScrollY === 'number') { + const el = height ? infScrollRef.current : getScrollableNode(); + if (el && el.scrollHeight > initialScrollY) { + el.scrollTo(0, initialScrollY); + } } - return null; - }; + }, []); + + // Effect 2a — reset the load guard when new data arrives. + // Deliberately decoupled from the IO observer (Effect 2b) so the observer + // is NOT recreated on every data load — it lives for the component's full + // mount lifetime and only reconnects when structural config changes. + useEffect(() => { + actionTriggeredRef.current = false; + setShowLoader(false); + }, [dataLength]); + + // Effect 2b — IntersectionObserver lifecycle. + // dataLength is intentionally absent from deps: the guard reset above handles + // the per-load reset. The observer only reconnects when the root, margin, or + // direction changes — typically never after initial mount. + useEffect(() => { + if (!hasMore) return; + + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const root: Element | null = height + ? infScrollRef.current + : getScrollableNode(); + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || actionTriggeredRef.current) return; + actionTriggeredRef.current = true; + setShowLoader(true); + nextRef.current(); // stable ref — safe to call without listing next in deps + }, + { + root, + rootMargin: buildRootMargin(scrollThreshold, inverse), + threshold: 0, + } + ); - onStart: EventListener = (evt: Event) => { - if (this.lastScrollTop) return; + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, scrollThreshold, inverse, height, getScrollableNode]); - this.dragging = true; + // Effect 3 — onScroll passthrough (only when prop is provided) + useEffect(() => { + if (!onScroll) return; - if (evt instanceof MouseEvent) { - this.startY = evt.pageY; - } else if (evt instanceof TouchEvent) { - this.startY = evt.touches[0].pageY; - } - this.currentY = this.startY; - - if (this._infScroll) { - this._infScroll.style.willChange = 'transform'; - this._infScroll.style.transition = `transform 0.2s cubic-bezier(0,0,0.31,1)`; - } - }; + const scrollEl: HTMLElement | Window | null = + (height ? infScrollRef.current : getScrollableNode()) ?? + (typeof window !== 'undefined' ? window : null); + if (!scrollEl) return; - onMove: EventListener = (evt: Event) => { - if (!this.dragging) return; + const handler = (e: Event) => { + setTimeout(() => onScroll(e as MouseEvent), 0); + }; - if (evt instanceof MouseEvent) { - this.currentY = evt.pageY; - } else if (evt instanceof TouchEvent) { - this.currentY = evt.touches[0].pageY; + scrollEl.addEventListener('scroll', handler as EventListener); + return () => + scrollEl.removeEventListener('scroll', handler as EventListener); + }, [onScroll, height, getScrollableNode]); + + // Effect 4 — Pull-to-refresh event listeners. + // refreshFunction and pullDownToRefreshThreshold are intentionally absent from + // deps — they are read via refs so listener re-registration is not needed when + // consumers pass new function references or change the threshold at runtime. + useEffect(() => { + if (!pullDownToRefresh) return; + + const scrollEl: HTMLElement | Window | null = + (height ? infScrollRef.current : getScrollableNode()) ?? + (typeof window !== 'undefined' ? window : null); + if (!scrollEl) return; + + // Measure pull-down indicator height after mount + if (pullDownRef.current?.firstChild) { + const dist = ( + pullDownRef.current.firstChild as HTMLElement + ).getBoundingClientRect().height; + maxPullDownDistanceRef.current = dist; + setMaxPullDownDistance(dist); } - // user is scrolling down to up - if (this.currentY < this.startY) return; - - if ( - this.currentY - this.startY >= - Number(this.props.pullDownToRefreshThreshold) - ) { - this.setState({ - pullToRefreshThresholdBreached: true, - }); - } + const onStart = (evt: Event) => { + // Only allow pull-to-refresh when the scroll container is at the very top. + // Replaces the old lastScrollTop ref which was never updated after removing + // the scroll event listener, making the original guard permanently a no-op. + const scrollTop = + scrollEl instanceof HTMLElement + ? scrollEl.scrollTop + : document.documentElement.scrollTop; + if (scrollTop > 0) return; + + draggingRef.current = true; + + if (evt instanceof MouseEvent) { + startYRef.current = evt.pageY; + } else if (evt instanceof TouchEvent) { + startYRef.current = evt.touches[0].pageY; + } + currentYRef.current = startYRef.current; - // so you can drag upto 1.5 times of the maxPullDownDistance - if (this.currentY - this.startY > this.maxPullDownDistance * 1.5) return; + if (infScrollRef.current) { + infScrollRef.current.style.willChange = 'transform'; + infScrollRef.current.style.transition = + 'transform 0.2s cubic-bezier(0,0,0.31,1)'; + } + }; - if (this._infScroll) { - this._infScroll.style.overflow = 'visible'; - this._infScroll.style.transform = `translate3d(0px, ${ - this.currentY - this.startY - }px, 0px)`; - } - }; + const onMove = (evt: Event) => { + if (!draggingRef.current) return; - onEnd: EventListener = () => { - this.startY = 0; - this.currentY = 0; + if (evt instanceof MouseEvent) { + currentYRef.current = evt.pageY; + } else if (evt instanceof TouchEvent) { + currentYRef.current = evt.touches[0].pageY; + } - this.dragging = false; + // user is scrolling up — ignore + if (currentYRef.current < startYRef.current) return; - if (this.state.pullToRefreshThresholdBreached) { - this.props.refreshFunction && this.props.refreshFunction(); - this.setState({ - pullToRefreshThresholdBreached: false, - }); - } + const delta = currentYRef.current - startYRef.current; - requestAnimationFrame(() => { - // this._infScroll - if (this._infScroll) { - this._infScroll.style.overflow = 'auto'; - this._infScroll.style.transform = 'none'; - this._infScroll.style.willChange = 'unset'; + // Read via ref — no stale closure risk, no effect re-registration needed + if (delta >= pullThresholdRef.current) { + setPullToRefreshThresholdBreached(true); } - }); - }; - isElementAtTop(target: HTMLElement, scrollThreshold: string | number = 0.8) { - const clientHeight = - target === document.body || target === document.documentElement - ? window.screen.availHeight - : target.clientHeight; + // limit drag to 1.5x maxPullDownDistance + if (delta > maxPullDownDistanceRef.current * 1.5) return; - const threshold = parseThreshold(scrollThreshold); + if (infScrollRef.current) { + infScrollRef.current.style.overflow = 'visible'; + infScrollRef.current.style.transform = `translate3d(0px, ${delta}px, 0px)`; + } + }; - if (threshold.unit === ThresholdUnits.Pixel) { - return ( - target.scrollTop <= - threshold.value + clientHeight - target.scrollHeight + 1 - ); - } + const onEnd = () => { + startYRef.current = 0; + currentYRef.current = 0; + draggingRef.current = false; + + setPullToRefreshThresholdBreached((breached) => { + if (breached) { + // Read via ref — refreshFunction identity changes don't re-register listeners + refreshFunctionRef.current?.(); + } + return false; + }); - return ( - target.scrollTop <= - threshold.value / 100 + clientHeight - target.scrollHeight + 1 - ); - } - - isElementAtBottom( - target: HTMLElement, - scrollThreshold: string | number = 0.8 - ) { - const clientHeight = - target === document.body || target === document.documentElement - ? window.screen.availHeight - : target.clientHeight; - - const threshold = parseThreshold(scrollThreshold); - - if (threshold.unit === ThresholdUnits.Pixel) { - return ( - target.scrollTop + clientHeight >= target.scrollHeight - threshold.value - ); - } + requestAnimationFrame(() => { + if (infScrollRef.current) { + infScrollRef.current.style.overflow = 'auto'; + infScrollRef.current.style.transform = 'none'; + infScrollRef.current.style.willChange = 'unset'; + } + }); + }; - return ( - target.scrollTop + clientHeight >= - (threshold.value / 100) * target.scrollHeight - ); - } + scrollEl.addEventListener('touchstart', onStart as EventListener); + scrollEl.addEventListener('touchmove', onMove as EventListener); + scrollEl.addEventListener('touchend', onEnd as EventListener); + scrollEl.addEventListener('mousedown', onStart as EventListener); + scrollEl.addEventListener('mousemove', onMove as EventListener); + scrollEl.addEventListener('mouseup', onEnd as EventListener); + + return () => { + scrollEl.removeEventListener('touchstart', onStart as EventListener); + scrollEl.removeEventListener('touchmove', onMove as EventListener); + scrollEl.removeEventListener('touchend', onEnd as EventListener); + scrollEl.removeEventListener('mousedown', onStart as EventListener); + scrollEl.removeEventListener('mousemove', onMove as EventListener); + scrollEl.removeEventListener('mouseup', onEnd as EventListener); + }; + }, [pullDownToRefresh, height, getScrollableNode]); - onScrollListener = (event: MouseEvent) => { - if (typeof this.props.onScroll === 'function') { - // Execute this callback in next tick so that it does not affect the - // functionality of the library. - setTimeout(() => this.props.onScroll && this.props.onScroll(event), 0); - } + const containerStyle: CSSProperties = { + height: height ?? 'auto', + overflow: 'auto', + WebkitOverflowScrolling: 'touch', + ...style, + }; - const target = - this.props.height || this._scrollableNode - ? (event.target as HTMLElement) - : document.documentElement.scrollTop - ? document.documentElement - : document.body; - - // return immediately if the action has already been triggered, - // prevents multiple triggers. - if (this.actionTriggered) return; - - const atBottom = this.props.inverse - ? this.isElementAtTop(target, this.props.scrollThreshold) - : this.isElementAtBottom(target, this.props.scrollThreshold); - - // call the `next` function in the props to trigger the next data fetch - if (atBottom && this.props.hasMore) { - this.actionTriggered = true; - this.setState({ showLoader: true }); - this.props.next && this.props.next(); - } + const hasChildrenResolved = + hasChildren || !!(children && children instanceof Array && children.length); - this.lastScrollTop = target.scrollTop; - }; + const outerDivStyle: CSSProperties = + pullDownToRefresh && height ? { overflow: 'auto' } : {}; - render() { - const style = { - height: this.props.height || 'auto', - overflow: 'auto', - WebkitOverflowScrolling: 'touch', - ...this.props.style, - } as CSSProperties; - const hasChildren = - this.props.hasChildren || - !!( - this.props.children && - this.props.children instanceof Array && - this.props.children.length - ); + const sentinel = hasMore ? ( +
+ ) : null; - // because heighted infiniteScroll visualy breaks - // on drag down as overflow becomes visible - const outerDivStyle = - this.props.pullDownToRefresh && this.props.height - ? { overflow: 'auto' } - : {}; - return ( + return ( +
-
(this._infScroll = infScroll)} - style={style} - > - {this.props.pullDownToRefresh && ( + {pullDownToRefresh && ( +
(this._pullDown = pullDown)} + style={{ + position: 'absolute', + left: 0, + right: 0, + top: -1 * maxPullDownDistance, + }} > -
- {this.state.pullToRefreshThresholdBreached - ? this.props.releaseToRefreshContent - : this.props.pullDownToRefreshContent} -
+ {pullToRefreshThresholdBreached + ? releaseToRefreshContent + : pullDownToRefreshContent}
- )} - {this.props.children} - {!this.state.showLoader && - !hasChildren && - this.props.hasMore && - this.props.loader} - {this.state.showLoader && this.props.hasMore && this.props.loader} - {!this.props.hasMore && this.props.endMessage} -
+
+ )} + {children} + {!showLoader && !hasChildrenResolved && hasMore && loader} + {showLoader && hasMore && loader} + {sentinel} + {!hasMore && endMessage}
- ); - } +
+ ); } diff --git a/src/utils/buildRootMargin.ts b/src/utils/buildRootMargin.ts new file mode 100644 index 0000000..8654ac2 --- /dev/null +++ b/src/utils/buildRootMargin.ts @@ -0,0 +1,38 @@ +import { ThresholdUnits, parseThreshold } from './threshold'; + +/** + * Converts a scrollThreshold value into a CSS rootMargin string for IntersectionObserver. + * + * scrollThreshold represents "how far down the page before triggering" (e.g. 0.8 = 80%). + * IntersectionObserver's rootMargin extends the root's bounding box so the sentinel is + * detected before it actually enters the visible area. + * + * For a percentage threshold (e.g. 0.8 → 80%): + * remaining = 100% - 80% = 20% → rootMargin bottom = "20%" + * + * For a pixel threshold (e.g. "120px"): + * rootMargin bottom = "120px" + * + * Note: rootMargin percentages are relative to the root element's own dimensions, not + * scrollHeight. This is a close approximation of the original scroll-event behavior and + * is an intentional tradeoff for the performance benefit. + * + * For inverse mode the margin is applied to the top instead of the bottom. + */ +export function buildRootMargin( + scrollThreshold: number | string, + inverse: boolean +): string { + const threshold = parseThreshold(scrollThreshold); + + let margin: string; + if (threshold.unit === ThresholdUnits.Pixel) { + margin = `${threshold.value}px`; + } else { + // threshold.value is already in percent (e.g. 80 for 0.8 input) + const remaining = 100 - threshold.value; + margin = `${remaining}%`; + } + + return inverse ? `${margin} 0px 0px 0px` : `0px 0px ${margin} 0px`; +} From 53d7748ea31f592d32aa2b302f767ae4a9167da8 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Thu, 9 Apr 2026 00:40:12 +0530 Subject: [PATCH 03/11] test: update test suite for function component rewrite Migrate all tests to @testing-library/react, add IntersectionObserver mock in setup/, and add new test files for buildRootMargin utility and no-scrollbar behaviour. --- jest.config.js | 6 +- src/__tests__/bottom.test.tsx | 76 +++++++++++------- src/__tests__/buildRootMargin.test.ts | 53 +++++++++++++ src/__tests__/hasChildren.test.tsx | 8 ++ src/__tests__/index.test.tsx | 30 ++++---- src/__tests__/inverse.test.tsx | 54 +++++++------ src/__tests__/noScrollbar.test.tsx | 77 +++++++++++++++++++ src/__tests__/pullDown.test.tsx | 14 +++- src/__tests__/scrollableTarget.test.tsx | 55 ++++++++----- .../setup/intersectionObserverMock.ts | 57 ++++++++++++++ 10 files changed, 337 insertions(+), 93 deletions(-) create mode 100644 src/__tests__/buildRootMargin.test.ts create mode 100644 src/__tests__/noScrollbar.test.tsx create mode 100644 src/__tests__/setup/intersectionObserverMock.ts diff --git a/jest.config.js b/jest.config.js index d4155ac..638ddae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -119,7 +119,7 @@ module.exports = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], + setupFiles: ['/src/__tests__/setup/intersectionObserverMock.ts'], // The path to a module that runs some code to configure or set up the testing framework before each test // setupTestFrameworkScriptFile: null, @@ -143,9 +143,7 @@ module.exports = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ['/node_modules/', '/src/__tests__/setup/'], // The regexp pattern Jest uses to detect test files // testRegex: "", diff --git a/src/__tests__/bottom.test.tsx b/src/__tests__/bottom.test.tsx index 375137d..2ae829a 100644 --- a/src/__tests__/bottom.test.tsx +++ b/src/__tests__/bottom.test.tsx @@ -1,19 +1,17 @@ -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, act } from '@testing-library/react'; import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; describe('bottom detection triggers next', () => { beforeEach(() => { - jest.useFakeTimers(); + MockIntersectionObserver.instances = []; }); - afterEach(() => { - cleanup(); - jest.useRealTimers(); - }); + afterEach(cleanup); - it('calls next when scrolled to bottom (height container)', () => { + it('calls next when sentinel intersects (height container)', () => { const next = jest.fn(); - const { container } = render( + render( { ); - const node = container.querySelector( - '.infinite-scroll-component' - ) as HTMLElement; - - Object.defineProperty(node, 'clientHeight', { - configurable: true, - get: () => 100, - }); - Object.defineProperty(node, 'scrollHeight', { - configurable: true, - get: () => 200, - }); - Object.defineProperty(node, 'scrollTop', { - configurable: true, - get: () => 100, + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); }); - node.dispatchEvent(new Event('scroll')); + expect(next).toHaveBeenCalled(); + }); - jest.advanceTimersByTime(200); + it('does not call next when hasMore is false', () => { + const next = jest.fn(); + render( + +
+ + ); - expect(next).toHaveBeenCalled(); + // No observer is created when hasMore=false (no sentinel rendered) + expect(MockIntersectionObserver.instances).toHaveLength(0); + expect(next).not.toHaveBeenCalled(); + }); + + it('does not call next twice before dataLength changes', () => { + const next = jest.fn(); + render( + +
+ + ); + + const observer = MockIntersectionObserver.instances[0]; + + act(() => { + observer.triggerIntersect(); + observer.triggerIntersect(); // second fire before dataLength changes + }); + + expect(next).toHaveBeenCalledTimes(1); }); }); diff --git a/src/__tests__/buildRootMargin.test.ts b/src/__tests__/buildRootMargin.test.ts new file mode 100644 index 0000000..8cf72e2 --- /dev/null +++ b/src/__tests__/buildRootMargin.test.ts @@ -0,0 +1,53 @@ +import { buildRootMargin } from '../utils/buildRootMargin'; + +describe('buildRootMargin', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('normal (non-inverse) mode', () => { + it('converts default number threshold (0.8) to bottom margin', () => { + expect(buildRootMargin(0.8, false)).toBe('0px 0px 20% 0px'); + }); + + it('converts 0.5 number threshold', () => { + expect(buildRootMargin(0.5, false)).toBe('0px 0px 50% 0px'); + }); + + it('converts 1.0 number threshold (trigger at 100%)', () => { + expect(buildRootMargin(1.0, false)).toBe('0px 0px 0% 0px'); + }); + + it('converts percent string threshold', () => { + expect(buildRootMargin('80%', false)).toBe('0px 0px 20% 0px'); + }); + + it('converts pixel string threshold', () => { + expect(buildRootMargin('120px', false)).toBe('0px 0px 120px 0px'); + }); + + it('converts 0px threshold', () => { + expect(buildRootMargin('0px', false)).toBe('0px 0px 0px 0px'); + }); + }); + + describe('inverse mode', () => { + it('converts default number threshold (0.8) to top margin', () => { + expect(buildRootMargin(0.8, true)).toBe('20% 0px 0px 0px'); + }); + + it('converts pixel string threshold to top margin', () => { + expect(buildRootMargin('120px', true)).toBe('120px 0px 0px 0px'); + }); + + it('converts percent string threshold to top margin', () => { + expect(buildRootMargin('50%', true)).toBe('50% 0px 0px 0px'); + }); + }); +}); diff --git a/src/__tests__/hasChildren.test.tsx b/src/__tests__/hasChildren.test.tsx index 367fcac..4979d95 100644 --- a/src/__tests__/hasChildren.test.tsx +++ b/src/__tests__/hasChildren.test.tsx @@ -1,7 +1,12 @@ import { render, cleanup } from '@testing-library/react'; import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; describe('hasChildren logic and loader visibility', () => { + beforeEach(() => { + MockIntersectionObserver.instances = []; + }); + afterEach(cleanup); it('shows loader when hasMore and no children', () => { @@ -20,6 +25,8 @@ describe('hasChildren logic and loader visibility', () => { }); it('does not show loader when hasChildren=true and non-array child', () => { + // With IO the loader is only shown synchronously when there are no children. + // hasChildren=true suppresses the immediate loader render — IO must fire first. const { queryByText } = render( {
child
); + // IO has not fired yet, so showLoader=false AND hasChildren=true suppresses the immediate render expect(queryByText('Loading...')).toBeNull(); }); }); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 1453402..13c782f 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,9 +1,14 @@ -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, act } from '@testing-library/react'; import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; describe('React Infinite Scroll Component', () => { const originalConsoleError = console.error; + beforeEach(() => { + MockIntersectionObserver.instances = []; + }); + afterEach(() => { cleanup(); console.error = originalConsoleError; @@ -82,6 +87,7 @@ describe('React Infinite Scroll Component', () => { expect(setTimeoutSpy).toHaveBeenCalled(); expect(onScrollMock).toHaveBeenCalled(); setTimeoutSpy.mockRestore(); + jest.useRealTimers(); }); describe('When missing the dataLength prop', () => { @@ -101,7 +107,7 @@ describe('React Infinite Scroll Component', () => { describe('When user scrolls to the bottom', () => { it('does not show loader if hasMore is false', () => { - const { container, queryByText } = render( + const { queryByText } = render( {
); - - const scrollEvent = new Event('scroll'); - const node = container.querySelector( - '.infinite-scroll-component' - ) as HTMLElement; - node.dispatchEvent(scrollEvent); + // No IO observer created, loader never shown expect(queryByText('Loading...')).toBeFalsy(); }); - it('shows loader if hasMore is true', () => { - const { container, getByText } = render( + it('shows loader if hasMore is true after IO fires', () => { + const { getByText } = render( { ); - const scrollEvent = new Event('scroll'); - const node = container.querySelector( - '.infinite-scroll-component' - ) as HTMLElement; - node.dispatchEvent(scrollEvent); + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); + }); + expect(getByText('Loading...')).toBeTruthy(); }); }); diff --git a/src/__tests__/inverse.test.tsx b/src/__tests__/inverse.test.tsx index 60dfa8a..cd70cc8 100644 --- a/src/__tests__/inverse.test.tsx +++ b/src/__tests__/inverse.test.tsx @@ -1,16 +1,17 @@ -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, act } from '@testing-library/react'; import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; describe('inverse mode triggers next near top', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => { - cleanup(); - jest.useRealTimers(); + beforeEach(() => { + MockIntersectionObserver.instances = []; }); - it('calls next when at top (inverse)', () => { + afterEach(cleanup); + + it('calls next when sentinel intersects in inverse mode', () => { const next = jest.fn(); - const { container } = render( + render( { ); - const node = container.querySelector( - '.infinite-scroll-component' - ) as HTMLElement; - - Object.defineProperty(node, 'clientHeight', { - configurable: true, - get: () => 100, - }); - Object.defineProperty(node, 'scrollHeight', { - configurable: true, - get: () => 1000, - }); - Object.defineProperty(node, 'scrollTop', { - configurable: true, - get: () => 0, + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); }); - node.dispatchEvent(new Event('scroll')); + expect(next).toHaveBeenCalled(); + }); - jest.advanceTimersByTime(200); + it('applies top rootMargin in inverse mode', () => { + const next = jest.fn(); + render( + +
+ + ); - expect(next).toHaveBeenCalled(); + const { options } = MockIntersectionObserver.instances[0]; + // inverse mode: margin applies to top, so rootMargin starts with a non-zero value + expect(options.rootMargin).toBe('20% 0px 0px 0px'); }); }); diff --git a/src/__tests__/noScrollbar.test.tsx b/src/__tests__/noScrollbar.test.tsx new file mode 100644 index 0000000..8ee0395 --- /dev/null +++ b/src/__tests__/noScrollbar.test.tsx @@ -0,0 +1,77 @@ +import { render, cleanup, act } from '@testing-library/react'; +import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; + +describe('next is called when content fits viewport (no scrollbar)', () => { + beforeEach(() => { + MockIntersectionObserver.instances = []; + }); + + afterEach(cleanup); + + it('calls next when IO fires on mount (sentinel immediately visible)', () => { + const next = jest.fn(); + render( + + {[1, 2, 3, 4, 5].map((i) => ( +
{i}
+ ))} +
+ ); + + // Simulate IO detecting that the sentinel is immediately visible + // (i.e., content is shorter than the container — no scrollbar) + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); + }); + + expect(next).toHaveBeenCalled(); + }); + + it('does not call next when IO never fires (content overflows)', () => { + const next = jest.fn(); + render( + + {Array.from({ length: 20 }, (_, i) => ( +
+ {i} +
+ ))} +
+ ); + + // IO hasn't fired — next should not be called + expect(next).not.toHaveBeenCalled(); + }); + + it('does not call next when hasMore is false', () => { + const next = jest.fn(); + render( + + {[1, 2, 3].map((i) => ( +
{i}
+ ))} +
+ ); + + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/pullDown.test.tsx b/src/__tests__/pullDown.test.tsx index 68bdb8f..3a6ad31 100644 --- a/src/__tests__/pullDown.test.tsx +++ b/src/__tests__/pullDown.test.tsx @@ -1,4 +1,4 @@ -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, act } from '@testing-library/react'; import InfiniteScroll from '../index'; describe('pull down to refresh', () => { @@ -52,17 +52,23 @@ describe('pull down to refresh', () => { const down = new MouseEvent('mousedown', { bubbles: true } as any); Object.defineProperty(down, 'pageY', { value: 0 }); - node.dispatchEvent(down); + act(() => { + node.dispatchEvent(down); + }); const move = new MouseEvent('mousemove', { bubbles: true } as any); Object.defineProperty(move, 'pageY', { value: 60 }); - node.dispatchEvent(move); + act(() => { + node.dispatchEvent(move); + }); // verify transform is applied during drag expect(node.style.transform).toBe('translate3d(0px, 60px, 0px)'); const up = new MouseEvent('mouseup', { bubbles: true } as any); - node.dispatchEvent(up); + act(() => { + node.dispatchEvent(up); + }); expect(refresh).toHaveBeenCalled(); }); diff --git a/src/__tests__/scrollableTarget.test.tsx b/src/__tests__/scrollableTarget.test.tsx index 0e8eada..a86a576 100644 --- a/src/__tests__/scrollableTarget.test.tsx +++ b/src/__tests__/scrollableTarget.test.tsx @@ -1,33 +1,23 @@ -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, act } from '@testing-library/react'; import InfiniteScroll from '../index'; +import { MockIntersectionObserver } from './setup/intersectionObserverMock'; describe('scrollableTarget as element id', () => { - beforeEach(() => jest.useFakeTimers()); + beforeEach(() => { + MockIntersectionObserver.instances = []; + }); + afterEach(() => { cleanup(); - jest.useRealTimers(); }); - it('listens on the provided scrollable target', () => { + it('uses the provided scrollable target as the IO root', () => { const next = jest.fn(); const target = document.createElement('div'); target.id = 'scrollableDiv'; document.body.appendChild(target); - Object.defineProperty(target, 'clientHeight', { - configurable: true, - get: () => 100, - }); - Object.defineProperty(target, 'scrollHeight', { - configurable: true, - get: () => 200, - }); - Object.defineProperty(target, 'scrollTop', { - configurable: true, - get: () => 100, - }); - render( { ); - target.dispatchEvent(new Event('scroll')); + // IO root should be the resolved scrollable element + expect(MockIntersectionObserver.instances[0].options.root).toBe(target); - jest.advanceTimersByTime(200); + // Triggering intersection calls next + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); + }); expect(next).toHaveBeenCalled(); document.body.removeChild(target); }); + + it('accepts an HTMLElement directly as scrollableTarget', () => { + const next = jest.fn(); + + const target = document.createElement('div'); + document.body.appendChild(target); + + render( + +
+ + ); + + expect(MockIntersectionObserver.instances[0].options.root).toBe(target); + + document.body.removeChild(target); + }); }); diff --git a/src/__tests__/setup/intersectionObserverMock.ts b/src/__tests__/setup/intersectionObserverMock.ts new file mode 100644 index 0000000..7ed5744 --- /dev/null +++ b/src/__tests__/setup/intersectionObserverMock.ts @@ -0,0 +1,57 @@ +/** + * Mock for IntersectionObserver — jsdom does not implement it. + * This file is registered in jest.config.js as a setupFile so the global + * is available in every test. Tests that want to simulate intersections + * import MockIntersectionObserver and call triggerIntersect(). + */ +export class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + + callback: (entries: IntersectionObserverEntry[]) => void; + options: IntersectionObserverInit; + observedElements: Element[] = []; + + constructor( + callback: (entries: IntersectionObserverEntry[]) => void, + options: IntersectionObserverInit = {} + ) { + this.callback = callback; + this.options = options; + MockIntersectionObserver.instances.push(this); + } + + observe(el: Element) { + this.observedElements.push(el); + } + + unobserve(el: Element) { + this.observedElements = this.observedElements.filter((e) => e !== el); + } + + disconnect() { + this.observedElements = []; + } + + /** Simulate the sentinel element becoming visible. */ + triggerIntersect(el?: Element) { + const target = el ?? this.observedElements[0]; + this.callback([ + { isIntersecting: true, target } as IntersectionObserverEntry, + ]); + } + + /** Simulate the sentinel element leaving the viewport. */ + triggerNoIntersect(el?: Element) { + const target = el ?? this.observedElements[0]; + this.callback([ + { isIntersecting: false, target } as IntersectionObserverEntry, + ]); + } +} + +// Assign globally so `new IntersectionObserver(...)` in the component resolves to the mock. +Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver, +}); From bc691b9f0318842d4b404868cd8150a818481ce6 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Thu, 9 Apr 2026 00:40:26 +0530 Subject: [PATCH 04/11] fix: spread array correctly in story next() The previous code pushed a nested array instead of spreading items, causing incorrect data shape in the Storybook window scroll story. --- src/stories/WindowInfiniteScrollComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stories/WindowInfiniteScrollComponent.tsx b/src/stories/WindowInfiniteScrollComponent.tsx index f41caf9..5aec3b1 100644 --- a/src/stories/WindowInfiniteScrollComponent.tsx +++ b/src/stories/WindowInfiniteScrollComponent.tsx @@ -13,7 +13,7 @@ export default class WindowInfiniteScrollComponent extends React.Component< next = () => { setTimeout(() => { - const newData = [...this.state.data, new Array(100).fill(1)]; + const newData = [...this.state.data, ...new Array(100).fill(1)]; this.setState({ data: newData }); }, 2000); }; From 85c3df77a1026799f0c2d1c66a2f3e540a493da9 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sat, 11 Apr 2026 19:35:09 +0530 Subject: [PATCH 05/11] test: add coverage for window scroll mode, initialScrollY, touch PTR, null scrollableTarget, and missing refreshFunction --- src/__tests__/bottom.test.tsx | 23 +++++++ src/__tests__/index.test.tsx | 82 +++++++++++++++++++++++++ src/__tests__/pullDown.test.tsx | 44 +++++++++++++ src/__tests__/scrollableTarget.test.tsx | 21 +++++++ 4 files changed, 170 insertions(+) diff --git a/src/__tests__/bottom.test.tsx b/src/__tests__/bottom.test.tsx index 2ae829a..f409f32 100644 --- a/src/__tests__/bottom.test.tsx +++ b/src/__tests__/bottom.test.tsx @@ -73,4 +73,27 @@ describe('bottom detection triggers next', () => { expect(next).toHaveBeenCalledTimes(1); }); + + it('uses null root (viewport) in window scroll mode', () => { + const next = jest.fn(); + render( + +
+ + ); + + // No height, no scrollableTarget → root must be null (viewport IO) + expect(MockIntersectionObserver.instances[0].options.root).toBeNull(); + + act(() => { + MockIntersectionObserver.instances[0].triggerIntersect(); + }); + + expect(next).toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 13c782f..ecafeb7 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -105,6 +105,88 @@ describe('React Infinite Scroll Component', () => { }); }); + describe('When pullDownToRefresh is true but refreshFunction is missing', () => { + it('throws an error', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + render( + {}} + pullDownToRefresh + > +
+ + ) + ).toThrow('Mandatory prop "refreshFunction" missing'); + + consoleSpy.mockRestore(); + }); + }); + + describe('initialScrollY', () => { + it('calls scrollTo on mount when scrollHeight exceeds initialScrollY', () => { + const scrollToSpy = jest.fn(); + const originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = scrollToSpy as any; + + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => 500, + }); + + render( + {}} + height={100} + initialScrollY={200} + > +
+ + ); + + expect(scrollToSpy).toHaveBeenCalledWith(0, 200); + + HTMLElement.prototype.scrollTo = originalScrollTo; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => 0, + }); + }); + + it('does not call scrollTo when scrollHeight is insufficient', () => { + const scrollToSpy = jest.fn(); + const originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = scrollToSpy as any; + + // scrollHeight defaults to 0 in jsdom — less than initialScrollY + render( + {}} + height={100} + initialScrollY={200} + > +
+ + ); + + expect(scrollToSpy).not.toHaveBeenCalled(); + + HTMLElement.prototype.scrollTo = originalScrollTo; + }); + }); + describe('When user scrolls to the bottom', () => { it('does not show loader if hasMore is false', () => { const { queryByText } = render( diff --git a/src/__tests__/pullDown.test.tsx b/src/__tests__/pullDown.test.tsx index 3a6ad31..5ec65d7 100644 --- a/src/__tests__/pullDown.test.tsx +++ b/src/__tests__/pullDown.test.tsx @@ -72,4 +72,48 @@ describe('pull down to refresh', () => { expect(refresh).toHaveBeenCalled(); }); + + it('calls refreshFunction after touch pull past threshold', () => { + const refresh = jest.fn(); + const { container } = render( + {}} + height={200} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={refresh} + pullDownToRefreshContent={
Pull
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + const touchStart = new TouchEvent('touchstart', { bubbles: true }); + Object.defineProperty(touchStart, 'touches', { value: [{ pageY: 0 }] }); + act(() => { + node.dispatchEvent(touchStart); + }); + + const touchMove = new TouchEvent('touchmove', { bubbles: true }); + Object.defineProperty(touchMove, 'touches', { value: [{ pageY: 60 }] }); + act(() => { + node.dispatchEvent(touchMove); + }); + + expect(node.style.transform).toBe('translate3d(0px, 60px, 0px)'); + + const touchEnd = new TouchEvent('touchend', { bubbles: true }); + act(() => { + node.dispatchEvent(touchEnd); + }); + + expect(refresh).toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/scrollableTarget.test.tsx b/src/__tests__/scrollableTarget.test.tsx index a86a576..0d35573 100644 --- a/src/__tests__/scrollableTarget.test.tsx +++ b/src/__tests__/scrollableTarget.test.tsx @@ -66,4 +66,25 @@ describe('scrollableTarget as element id', () => { document.body.removeChild(target); }); + + it('warns when scrollableTarget is null', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render( + {}} + scrollableTarget={null} + > +
+ + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('scrollableTarget but it is null') + ); + warnSpy.mockRestore(); + }); }); From a89dda74551c7aac71283ca58f5fcf4899d1fac6 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sat, 11 Apr 2026 19:45:58 +0530 Subject: [PATCH 06/11] =?UTF-8?q?test:=20close=20remaining=20coverage=20ga?= =?UTF-8?q?ps=20=E2=80=94=20PTR=20edge=20cases,=20onScroll=20window=20mode?= =?UTF-8?q?,=20initialScrollY=20via=20scrollableTarget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 7 tests covering: - PTR: scrollTop > 0 guard blocks drag start - PTR: upward pull (currentY < startY) ignored - PTR: delta capped at 1.5× maxPullDownDistance - PTR: releaseToRefreshContent shown when threshold breached - PTR: window-scroll mode (no height) — listeners on window, exercises document.documentElement.scrollTop branch - onScroll: window fallback when no height or scrollableTarget - initialScrollY: scrolls scrollableTarget element (no height prop path) --- src/__tests__/index.test.tsx | 50 ++++++++ src/__tests__/pullDown.test.tsx | 214 ++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index ecafeb7..cae4650 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -90,6 +90,28 @@ describe('React Infinite Scroll Component', () => { jest.useRealTimers(); }); + it('calls scroll handler via window when no height or scrollableTarget', () => { + jest.useFakeTimers(); + const onScrollMock = jest.fn(); + + render( + {}} + > +
+ + ); + + window.dispatchEvent(new Event('scroll')); + jest.runOnlyPendingTimers(); + expect(onScrollMock).toHaveBeenCalled(); + jest.useRealTimers(); + }); + describe('When missing the dataLength prop', () => { it('throws an error', () => { const consoleSpy = jest @@ -185,6 +207,34 @@ describe('React Infinite Scroll Component', () => { HTMLElement.prototype.scrollTo = originalScrollTo; }); + + it('scrolls scrollableTarget to initialScrollY when scrollHeight is sufficient', () => { + const target = document.createElement('div'); + const scrollToSpy = jest.fn(); + target.scrollTo = scrollToSpy as unknown as typeof target.scrollTo; + Object.defineProperty(target, 'scrollHeight', { + configurable: true, + get: () => 500, + }); + document.body.appendChild(target); + + render( + {}} + scrollableTarget={target} + initialScrollY={200} + > +
+ + ); + + expect(scrollToSpy).toHaveBeenCalledWith(0, 200); + + document.body.removeChild(target); + }); }); describe('When user scrolls to the bottom', () => { diff --git a/src/__tests__/pullDown.test.tsx b/src/__tests__/pullDown.test.tsx index 5ec65d7..614ad74 100644 --- a/src/__tests__/pullDown.test.tsx +++ b/src/__tests__/pullDown.test.tsx @@ -116,4 +116,218 @@ describe('pull down to refresh', () => { expect(refresh).toHaveBeenCalled(); }); + + it('does not start drag when scrollTop > 0', () => { + const refresh = jest.fn(); + const { container } = render( + {}} + height={200} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={refresh} + pullDownToRefreshContent={
Pull
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + // Simulate container already scrolled down + Object.defineProperty(node, 'scrollTop', { configurable: true, value: 50 }); + + const down = new MouseEvent('mousedown', { bubbles: true } as any); + Object.defineProperty(down, 'pageY', { value: 0 }); + act(() => { + node.dispatchEvent(down); + }); + + // Move should have no effect since drag was never started + const move = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move, 'pageY', { value: 60 }); + act(() => { + node.dispatchEvent(move); + }); + + expect(node.style.transform).toBe(''); + + // Reset scrollTop + Object.defineProperty(node, 'scrollTop', { configurable: true, value: 0 }); + }); + + it('ignores upward pull (currentY < startY)', () => { + const refresh = jest.fn(); + const { container } = render( + {}} + height={200} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={refresh} + pullDownToRefreshContent={
Pull
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + const down = new MouseEvent('mousedown', { bubbles: true } as any); + Object.defineProperty(down, 'pageY', { value: 100 }); + act(() => { + node.dispatchEvent(down); + }); + + // Move upward (pageY < startY) + const move = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move, 'pageY', { value: 40 }); + act(() => { + node.dispatchEvent(move); + }); + + // No transform applied for upward pull + expect(node.style.transform).toBe(''); + }); + + it('caps transform at 1.5x maxPullDownDistance', () => { + const refresh = jest.fn(); + const { container } = render( + {}} + height={200} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={refresh} + pullDownToRefreshContent={
Pull
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + const down = new MouseEvent('mousedown', { bubbles: true } as any); + Object.defineProperty(down, 'pageY', { value: 0 }); + act(() => { + node.dispatchEvent(down); + }); + + // Pull 60px — within cap (100 * 1.5 = 150), transform applied + const move1 = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move1, 'pageY', { value: 60 }); + act(() => { + node.dispatchEvent(move1); + }); + expect(node.style.transform).toBe('translate3d(0px, 60px, 0px)'); + + // Pull 200px — exceeds cap (150px), transform NOT updated + const move2 = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move2, 'pageY', { value: 200 }); + act(() => { + node.dispatchEvent(move2); + }); + // Transform stays at 60px because the 200px delta exceeds the 1.5x cap + expect(node.style.transform).toBe('translate3d(0px, 60px, 0px)'); + }); + + it('calls refreshFunction in window-scroll PTR mode (no height)', () => { + // Exercises the scrollEl = window path (line 218 false branch) in PTR onStart + const refresh = jest.fn(); + const { container } = render( + {}} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={refresh} + pullDownToRefreshContent={
Pull
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + // Listeners are attached to window when there is no height prop + const down = new MouseEvent('mousedown', { bubbles: true } as any); + Object.defineProperty(down, 'pageY', { value: 0 }); + act(() => { + window.dispatchEvent(down); + }); + + const move = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move, 'pageY', { value: 60 }); + act(() => { + window.dispatchEvent(move); + }); + + expect(node.style.transform).toBe('translate3d(0px, 60px, 0px)'); + + const up = new MouseEvent('mouseup', { bubbles: true } as any); + act(() => { + window.dispatchEvent(up); + }); + + expect(refresh).toHaveBeenCalled(); + }); + + it('shows releaseToRefreshContent when threshold is breached', () => { + const { container, getByText, queryByText } = render( + {}} + height={200} + pullDownToRefresh + pullDownToRefreshThreshold={50} + refreshFunction={() => {}} + pullDownToRefreshContent={
Pull down
} + releaseToRefreshContent={
Release to refresh
} + > +
+ + ); + + const node = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + + expect(queryByText('Release to refresh')).toBeNull(); + + const down = new MouseEvent('mousedown', { bubbles: true } as any); + Object.defineProperty(down, 'pageY', { value: 0 }); + act(() => { + node.dispatchEvent(down); + }); + + // Pull past threshold (60 > 50) + const move = new MouseEvent('mousemove', { bubbles: true } as any); + Object.defineProperty(move, 'pageY', { value: 60 }); + act(() => { + node.dispatchEvent(move); + }); + + expect(getByText('Release to refresh')).toBeTruthy(); + }); }); From ca35db767dcd76bb77184168335d5aab3056db8d Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sun, 12 Apr 2026 19:15:11 +0530 Subject: [PATCH 07/11] fix: resolve all known issues ahead of 7.1.0 release - Fix inverse sentinel placement: sentinel now renders before children when inverse=true so the IO top-margin fires correctly when scrolling up - Add SSR guard: typeof IntersectionObserver === 'undefined' check in Effect 2b prevents crash in Next.js App Router server components - Fix onScroll prop type: MouseEvent -> UIEvent (scroll events are UIEvents) - Fix defaultThreshold.value: 0.8 -> 80 (was computing 99.2% rootMargin instead of 20% on invalid scrollThreshold input) - Add exports field to package.json for Node ESM and Next.js 13+ App Router subpath resolution; main/module kept for older bundler fallback - Bump version to 7.1.0 - Fix pre-existing ts-check breakage: replace global with globalThis in test files, add resolveJsonModule for JSON import, exclude stories from tsconfig (storybook types not installed in devDependencies) --- package.json | 11 ++++- src/__tests__/index.test.tsx | 2 +- src/__tests__/inverse.test.tsx | 45 +++++++++++++++++++ src/__tests__/package.test.ts | 8 +--- .../setup/intersectionObserverMock.ts | 2 +- src/__tests__/threshold.test.ts | 4 +- src/index.tsx | 8 ++-- src/utils/threshold.ts | 2 +- tsconfig.json | 3 +- 9 files changed, 67 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index d632062..e35181b 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,22 @@ { "name": "react-infinite-scroll-component", - "version": "7.0.1", + "version": "7.1.0", "description": "An Infinite Scroll component in react.", "engines": { "node": ">=20.0.0" }, "source": "src/index.tsx", "main": "dist/index.js", - "unpkg": "dist/index.umd.js", "module": "dist/index.es.js", + "unpkg": "dist/index.umd.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "scripts": { "build": "rimraf dist && rollup -c", "prepublish": "yarn build", diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index cae4650..99881cb 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -61,7 +61,7 @@ describe('React Infinite Scroll Component', () => { it('calls scroll handler if provided, when user scrolls', () => { jest.useFakeTimers(); - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); const onScrollMock = jest.fn(); const { container } = render( diff --git a/src/__tests__/inverse.test.tsx b/src/__tests__/inverse.test.tsx index cd70cc8..a836092 100644 --- a/src/__tests__/inverse.test.tsx +++ b/src/__tests__/inverse.test.tsx @@ -51,4 +51,49 @@ describe('inverse mode triggers next near top', () => { // inverse mode: margin applies to top, so rootMargin starts with a non-zero value expect(options.rootMargin).toBe('20% 0px 0px 0px'); }); + + it('renders sentinel as first child in inverse mode', () => { + const { container } = render( + {}} + height={100} + inverse + > +
+ + ); + + const inner = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + // sentinel must be the first DOM child so the IO top-margin fires correctly + expect(inner.firstElementChild).toBe( + MockIntersectionObserver.instances[0].observedElements[0] + ); + }); + + it('renders sentinel as last child in normal (non-inverse) mode', () => { + const { container } = render( + {}} + height={100} + > +
+ + ); + + const inner = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + // sentinel must be the last DOM child for the IO bottom-margin to work + expect(inner.lastElementChild).toBe( + MockIntersectionObserver.instances[0].observedElements[0] + ); + }); }); diff --git a/src/__tests__/package.test.ts b/src/__tests__/package.test.ts index 7254d37..fc9d1e7 100644 --- a/src/__tests__/package.test.ts +++ b/src/__tests__/package.test.ts @@ -1,5 +1,3 @@ -export {}; - /** * Validates package.json fields that affect consumers at install time. * These checks run on every `yarn test` invocation — no extra infrastructure needed. @@ -8,11 +6,7 @@ export {}; * that block React 18/19 consumers at npm install, like #419. */ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const pkg = require('../../package.json') as { - peerDependencies: Record; - devDependencies: Record; -}; +import pkg from '../../package.json'; describe('package.json — peer dependency ranges', () => { it('react peer dep uses >= (open-ended), not ^ (caret-bounded)', () => { diff --git a/src/__tests__/setup/intersectionObserverMock.ts b/src/__tests__/setup/intersectionObserverMock.ts index 7ed5744..9b31311 100644 --- a/src/__tests__/setup/intersectionObserverMock.ts +++ b/src/__tests__/setup/intersectionObserverMock.ts @@ -50,7 +50,7 @@ export class MockIntersectionObserver { } // Assign globally so `new IntersectionObserver(...)` in the component resolves to the mock. -Object.defineProperty(global, 'IntersectionObserver', { +Object.defineProperty(globalThis, 'IntersectionObserver', { writable: true, configurable: true, value: MockIntersectionObserver, diff --git a/src/__tests__/threshold.test.ts b/src/__tests__/threshold.test.ts index d01fb14..7deb8fa 100644 --- a/src/__tests__/threshold.test.ts +++ b/src/__tests__/threshold.test.ts @@ -29,14 +29,14 @@ describe('parseThreshold', () => { it('warns and returns default for invalid string', () => { const t = parseThreshold('foo' as any); expect(t.unit).toBe(ThresholdUnits.Percent); - expect(t.value).toBe(0.8); + expect(t.value).toBe(80); expect(warnSpy).toHaveBeenCalled(); }); it('warns and returns default for non-string/number', () => { const t = parseThreshold(null as unknown as any); expect(t.unit).toBe(ThresholdUnits.Percent); - expect(t.value).toBe(0.8); + expect(t.value).toBe(80); expect(warnSpy).toHaveBeenCalled(); }); }); diff --git a/src/index.tsx b/src/index.tsx index 3a286da..2ece470 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,7 +27,7 @@ export interface Props { releaseToRefreshContent?: ReactNode; pullDownToRefreshThreshold?: number; refreshFunction?: Fn; - onScroll?: (e: MouseEvent) => any; + onScroll?: (e: UIEvent) => any; dataLength: number; initialScrollY?: number; className?: string; @@ -145,6 +145,7 @@ export default function InfiniteScroll({ // direction changes — typically never after initial mount. useEffect(() => { if (!hasMore) return; + if (typeof IntersectionObserver === 'undefined') return; const sentinel = sentinelRef.current; if (!sentinel) return; @@ -181,7 +182,7 @@ export default function InfiniteScroll({ if (!scrollEl) return; const handler = (e: Event) => { - setTimeout(() => onScroll(e as MouseEvent), 0); + setTimeout(() => onScroll(e as UIEvent), 0); }; scrollEl.addEventListener('scroll', handler as EventListener); @@ -329,6 +330,7 @@ export default function InfiniteScroll({ ref={infScrollRef} style={containerStyle} > + {inverse && sentinel} {pullDownToRefresh && (
diff --git a/src/utils/threshold.ts b/src/utils/threshold.ts index d6b5fbf..14ff88e 100644 --- a/src/utils/threshold.ts +++ b/src/utils/threshold.ts @@ -5,7 +5,7 @@ export const ThresholdUnits = { const defaultThreshold = { unit: ThresholdUnits.Percent, - value: 0.8, + value: 80, }; export function parseThreshold(scrollThreshold: string | number) { diff --git a/tsconfig.json b/tsconfig.json index 565e354..a490c8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,6 +48,7 @@ ] /* Type declaration files to be included in compilation. */, // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "resolveJsonModule": true /* Allow importing .json files (used in package.test.ts). */, "skipLibCheck": true, "forceConsistentCasingInFileNames": true // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ @@ -61,5 +62,5 @@ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ }, "include": ["src/**/*", "lint-staged.config.js", "jest.config.js"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/stories"] } From ca97ad04215ff0a86fe00ed4164ba1d7a4c79c1b Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sun, 12 Apr 2026 19:34:52 +0530 Subject: [PATCH 08/11] fix: repair broken CI pipelines - rollup.config.mjs: switch tsconfig from tsconfig.json to tsconfig.lib.json tsconfig.json includes root-level JS files (lint-staged.config.js, jest.config.js), making TypeScript compute rootDir as the project root and emit to dist/src/index.js. @rollup/plugin-typescript uses ts.getOutputFileNames which returns dist/index.js, so emittedFiles lookup always misses and the plugin falls through without transpiling. tsconfig.lib.json includes only src files, rootDir is inferred as src/, and both paths agree on dist/index.js. - .eslintrc.js: ignore src/stories since tsconfig.json now excludes it; @typescript-eslint/parser requires project-included files to parse type-aware rules. - pullDown.test.tsx: global -> globalThis to fix no-unsafe-member-access lint error. --- .eslintrc.js | 1 + rollup.config.mjs | 2 +- src/__tests__/pullDown.test.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 59681ef..40336d8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { version: '17.0', }, }, + ignorePatterns: ['src/stories/**'], rules: { '@typescript-eslint/prefer-regexp-exec': 'warn', '@typescript-eslint/ban-ts-comment': 'off', diff --git a/rollup.config.mjs b/rollup.config.mjs index ee4cd91..fe7ed45 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -31,5 +31,5 @@ export default { }, ], external: [...Object.keys(pkg.peerDependencies || {}), 'react/jsx-runtime'], - plugins: [resolve(), typescript({ tsconfig: './tsconfig.json' })], + plugins: [resolve(), typescript({ tsconfig: './tsconfig.lib.json' })], }; diff --git a/src/__tests__/pullDown.test.tsx b/src/__tests__/pullDown.test.tsx index 614ad74..39be836 100644 --- a/src/__tests__/pullDown.test.tsx +++ b/src/__tests__/pullDown.test.tsx @@ -7,7 +7,7 @@ describe('pull down to refresh', () => { beforeAll(() => { // ensure RAF exists // @ts-ignore - global.requestAnimationFrame = (cb: any) => cb(); + globalThis.requestAnimationFrame = (cb: any) => cb(); // Mock getBoundingClientRect to return a height for maxPullDownDistance Element.prototype.getBoundingClientRect = jest.fn(() => ({ height: 100, From f5e1341a4684fde292b6a5559278f426cb31d7d2 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sun, 12 Apr 2026 19:44:42 +0530 Subject: [PATCH 09/11] docs: update README for v7, add What's new section --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e9c968d..d88b1f7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ added. An infinite-scroll that actually works and super-simple to integrate!

} // below props only if you need pull down functionality - refreshFunction={this.refresh} + refreshFunction={refresh} pullDownToRefresh pullDownToRefreshThreshold={50} pullDownToRefreshContent={ @@ -66,15 +66,15 @@ added. An infinite-scroll that actually works and super-simple to integrate! > {/*Put the scroll bar always on the bottom*/} Loading...} scrollableTarget="scrollableDiv" > - {this.state.items.map((_, index) => ( + {items.map((_, index) => (
div - #{index}
@@ -89,6 +89,40 @@ The `InfiniteScroll` component can be used in three ways. - If your **scrollable** content is being rendered within a parent element that is already providing overflow scrollbars, you can set the `scrollableTarget` prop to reference the DOM element and use it's scrollbars for fetching more data. - Without setting either the `height` or `scrollableTarget` props, the scroll will happen at `document.body` like _Facebook's_ timeline scroll. +## What's new in v7 + +### IntersectionObserver-based triggering + +`next()` is now triggered by an `IntersectionObserver` watching an invisible sentinel element at the bottom of the list (top for `inverse` mode), rather than a scroll event listener. This means: + +- The threshold is checked once when the sentinel enters the viewport, not on every scroll tick. +- No missed triggers when content loads fast enough to skip the threshold. +- Better performance — no work done while the user is scrolling through content that is far from the threshold. + +### Zero runtime dependencies + +`throttle-debounce` has been removed. The package now ships with **zero runtime dependencies**. The `onScroll` callback receives every scroll event directly without throttling. + +### `scrollableTarget` accepts `HTMLElement` directly + +Previously `scrollableTarget` only accepted a string element ID. It now accepts `HTMLElement | string | null`, so you can pass a ref value directly: + +```jsx +const ref = useRef(null); +// ... +
+ + {items} + +
+``` + +### Rewritten as a function component + +The component is now a React function component. The public prop API is unchanged — no migration needed. + +--- + ## docs version wise [3.0.2](docs/README-3.0.2.md) @@ -114,7 +148,7 @@ The `InfiniteScroll` component can be used in three ways. | **dataLength** | number | set the length of the data.This will unlock the subsequent calls to next. | | **loader** | node | you can send a loader component to show while the component waits for the next load of data. e.g. `

Loading...

` or any fancy loader element | | **scrollThreshold** | number | string | A threshold value defining when `InfiniteScroll` will call `next`. Default value is `0.8`. It means the `next` will be called when user comes below 80% of the total height. If you pass threshold in pixels (`scrollThreshold="200px"`), `next` will be called once you scroll at least (100% - scrollThreshold) pixels down. | -| **onScroll** | function | a function that will listen to the scroll event on the scrolling container. Note that the scroll event is throttled, so you may not receive as many events as you would expect. | +| **onScroll** | function | a function that will listen to the scroll event on the scrolling container. | | **endMessage** | node | this message is shown to the user when he has seen all the records which means he's at the bottom and `hasMore` is `false` | | **className** | string | add any custom class you want | | **style** | object | any style which you want to override | From e95cd28f2e77f7b4cbf6197fb4f4654c9ac23db9 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sun, 12 Apr 2026 21:34:06 +0530 Subject: [PATCH 10/11] fix: wire up Babel compiler addon and automatic JSX runtime for Storybook v10 --- .storybook/main.ts | 2 +- babel.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index 92ffc17..ab7279d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,7 +5,7 @@ const config: StorybookConfig = { '../src/**/*.stories.@(js|jsx|ts|tsx)', '../src/stories/stories.tsx', ], - addons: ['@storybook/addon-essentials'], + addons: ['@storybook/addon-webpack5-compiler-babel'], framework: { name: '@storybook/react-webpack5', options: {}, diff --git a/babel.config.js b/babel.config.js index 03ab8bf..4945818 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,7 @@ module.exports = { presets: [ '@babel/preset-env', - '@babel/preset-react', + ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], }; From 615d17747b0008b5c5804f5347a437c6b46f9400 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Sun, 12 Apr 2026 21:34:16 +0530 Subject: [PATCH 11/11] fix: move inverse sentinel to last DOM child so it sits at visual top in column-reverse layout --- src/__tests__/inverse.test.tsx | 7 ++++--- src/index.tsx | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/inverse.test.tsx b/src/__tests__/inverse.test.tsx index a836092..34d6d31 100644 --- a/src/__tests__/inverse.test.tsx +++ b/src/__tests__/inverse.test.tsx @@ -52,7 +52,7 @@ describe('inverse mode triggers next near top', () => { expect(options.rootMargin).toBe('20% 0px 0px 0px'); }); - it('renders sentinel as first child in inverse mode', () => { + it('renders sentinel as last child in inverse mode', () => { const { container } = render( { const inner = container.querySelector( '.infinite-scroll-component' ) as HTMLElement; - // sentinel must be the first DOM child so the IO top-margin fires correctly - expect(inner.firstElementChild).toBe( + // sentinel is last DOM child; with flex-direction: column-reverse this puts + // it at the visual top, where the IO top-margin extension pre-triggers + expect(inner.lastElementChild).toBe( MockIntersectionObserver.instances[0].observedElements[0] ); }); diff --git a/src/index.tsx b/src/index.tsx index 2ece470..e7a6cf1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -330,7 +330,6 @@ export default function InfiniteScroll({ ref={infScrollRef} style={containerStyle} > - {inverse && sentinel} {pullDownToRefresh && (