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/.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/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 | 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', ], }; 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/package.json b/package.json index 9ceebbf..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", @@ -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", @@ -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", 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..fe7ed45 --- /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.lib.json' })], +}; diff --git a/src/__tests__/bottom.test.tsx b/src/__tests__/bottom.test.tsx index 375137d..f409f32 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, + 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( + +
+ + ); + + // 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 }); - 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( + +
+ + ); + + // 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__/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..99881cb 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; @@ -56,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( @@ -82,6 +87,29 @@ describe('React Infinite Scroll Component', () => { expect(setTimeoutSpy).toHaveBeenCalled(); expect(onScrollMock).toHaveBeenCalled(); setTimeoutSpy.mockRestore(); + 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', () => { @@ -99,9 +127,119 @@ 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; + }); + + 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', () => { 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..34d6d31 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'); + }); + + it('renders sentinel as last child in inverse mode', () => { + const { container } = render( + {}} + height={100} + inverse + > +
+ + ); + + const inner = container.querySelector( + '.infinite-scroll-component' + ) as HTMLElement; + // 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] + ); + }); + + 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__/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__/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__/pullDown.test.tsx b/src/__tests__/pullDown.test.tsx index 68bdb8f..39be836 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', () => { @@ -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, @@ -52,18 +52,282 @@ 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(); }); + + 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(); + }); + + 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(); + }); }); diff --git a/src/__tests__/scrollableTarget.test.tsx b/src/__tests__/scrollableTarget.test.tsx index 0e8eada..0d35573 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); + }); + + 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(); + }); }); diff --git a/src/__tests__/setup/intersectionObserverMock.ts b/src/__tests__/setup/intersectionObserverMock.ts new file mode 100644 index 0000000..9b31311 --- /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(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 5c5cce9..e7a6cf1 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; @@ -20,377 +27,331 @@ export interface Props { releaseToRefreshContent?: ReactNode; pullDownToRefreshThreshold?: number; refreshFunction?: Fn; - onScroll?: (e: MouseEvent) => any; + onScroll?: (e: UIEvent) => any; dataLength: number; initialScrollY?: number; 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; + if (typeof IntersectionObserver === 'undefined') 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 UIEvent), 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/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); }; 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`; +} 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 50b7250..a490c8f 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,14 @@ // "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'. */, + "resolveJsonModule": true /* Allow importing .json files (used in package.test.ts). */, "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 +61,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", "src/stories"] +} 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"