Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ added. An infinite-scroll that actually works and super-simple to integrate!
</p>
}
// below props only if you need pull down functionality
refreshFunction={this.refresh}
refreshFunction={refresh}
pullDownToRefresh
pullDownToRefreshThreshold={50}
pullDownToRefreshContent={
Expand Down Expand Up @@ -66,15 +66,15 @@ added. An infinite-scroll that actually works and super-simple to integrate!
>
{/*Put the scroll bar always on the bottom*/}
<InfiniteScroll
dataLength={this.state.items.length}
next={this.fetchMoreData}
dataLength={items.length}
next={fetchMoreData}
style={{ display: 'flex', flexDirection: 'column-reverse' }} //To put endMessage and loader to the top.
inverse={true} //
inverse={true}
hasMore={true}
loader={<h4>Loading...</h4>}
scrollableTarget="scrollableDiv"
>
{this.state.items.map((_, index) => (
{items.map((_, index) => (
<div style={style} key={index}>
div - #{index}
</div>
Expand All @@ -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);
// ...
<div ref={ref} style={{ height: 300, overflow: 'auto' }}>
<InfiniteScroll scrollableTarget={ref.current} ...>
{items}
</InfiniteScroll>
</div>
```

### 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)
Expand All @@ -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. `<h3>Loading...</h3>` or any fancy loader element |
| **scrollThreshold** | number &#124; 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 |
Expand Down
2 changes: 1 addition & 1 deletion babel.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-react',
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
};
6 changes: 2 additions & 4 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<rootDir>/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,
Expand All @@ -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/', '<rootDir>/src/__tests__/setup/'],

// The regexp pattern Jest uses to detect test files
// testRegex: "",
Expand Down
24 changes: 14 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -56,11 +63,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",
Expand All @@ -75,17 +83,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",
Expand Down
26 changes: 0 additions & 26 deletions rollup.config.js

This file was deleted.

35 changes: 35 additions & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -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.lib.json' })],
};
95 changes: 71 additions & 24 deletions src/__tests__/bottom.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InfiniteScroll
dataLength={0}
loader={'Loading...'}
Expand All @@ -26,26 +24,75 @@ describe('bottom detection triggers next', () => {
</InfiniteScroll>
);

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,
act(() => {
MockIntersectionObserver.instances[0].triggerIntersect();
});
Object.defineProperty(node, 'scrollTop', {
configurable: true,
get: () => 100,

expect(next).toHaveBeenCalled();
});

it('does not call next when hasMore is false', () => {
const next = jest.fn();
render(
<InfiniteScroll
dataLength={0}
loader={'Loading...'}
hasMore={false}
next={next}
height={100}
>
<div />
</InfiniteScroll>
);

// 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(
<InfiniteScroll
dataLength={0}
loader={'Loading...'}
hasMore={true}
next={next}
height={100}
>
<div />
</InfiniteScroll>
);

const observer = MockIntersectionObserver.instances[0];

act(() => {
observer.triggerIntersect();
observer.triggerIntersect(); // second fire before dataLength changes
});

node.dispatchEvent(new Event('scroll'));
expect(next).toHaveBeenCalledTimes(1);
});

jest.advanceTimersByTime(200);
it('uses null root (viewport) in window scroll mode', () => {
const next = jest.fn();
render(
<InfiniteScroll
dataLength={0}
loader={'Loading...'}
hasMore={true}
next={next}
>
<div />
</InfiniteScroll>
);

// 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();
});
Expand Down
Loading
Loading