{"_id":"@artemtutt/use-chat-virtualizer","_rev":"5-cbca295a9a0fc452e25eec55d2f59a85","name":"@artemtutt/use-chat-virtualizer","dist-tags":{"latest":"1.0.0"},"versions":{"0.1.0":{"name":"@artemtutt/use-chat-virtualizer","version":"0.1.0","keywords":["react","chat","virtualization","virtual-list","messages"],"author":{"name":"ArtemTutt"},"license":"MIT","_id":"@artemtutt/use-chat-virtualizer@0.1.0","maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"dist":{"shasum":"838a9aeb63e87ecd566d80cc9b9739fa45c37875","tarball":"https://registry.npmjs.org/@artemtutt/use-chat-virtualizer/-/use-chat-virtualizer-0.1.0.tgz","fileCount":9,"integrity":"sha512-uxkdCgXuD7mnZgtXAZv9iJdE40LoJV72OxIO2r6q6hfm1GzCNAJddh7N0f0DpY1UuPxNNuKfTfnCW3O7y1IA5g==","signatures":[{"sig":"MEQCICD02Es7BB81kkITMJo1vf0hcyt84ZfhcaalxrbBDSqnAiBD7cz68SDyDHy51oJv8uqp6zkxuFuLoz9Ro2Tl8JCkXw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":189577},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"3b513fc5a54c9715bde81b8d5023f71c596272c6","scripts":{"test":"vitest run","build":"tsup","check":"npm run typecheck && npm test && npm run build","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run check"},"_npmUser":{"name":"artemtutt","email":"karelov.crcur@gmail.com"},"repository":{"url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git","type":"git"},"_npmVersion":"10.9.2","description":"A low-level, chat-aware React virtualizer for dynamic message lists.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.1","vitest":"^3.2.4","react-dom":"^19.1.1","typescript":"^5.9.2","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@testing-library/react":"^16.3.0"},"peerDependencies":{"react":">=18.0.0"},"_npmOperationalInternal":{"tmp":"tmp/use-chat-virtualizer_0.1.0_1786087658586_0.8546659075587733","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@artemtutt/use-chat-virtualizer","version":"0.2.0","keywords":["react","chat","virtualization","virtual-list","messages"],"author":{"name":"ArtemTutt"},"license":"MIT","_id":"@artemtutt/use-chat-virtualizer@0.2.0","maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"dist":{"shasum":"504b9e84e8d95feb2df87d21b99eebefd0dd63e2","tarball":"https://registry.npmjs.org/@artemtutt/use-chat-virtualizer/-/use-chat-virtualizer-0.2.0.tgz","fileCount":9,"integrity":"sha512-4pxlU4p3N4FsE1/KvHQ8i+T8a1NZYPKEUUtdX3ygh3RkM1QBLwnTA9tFKr4Msj9xeECl3niK3emlNWNLIalb4A==","signatures":[{"sig":"MEQCIEM/RgnM6r8AAUpXjBbOkMgTQEF6scXKyODjmm0HEYdlAiA52/X5j/umtOQT1eY56hINJSvwW5agB1iVdbHf7xRd6Q==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":205489},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"4494924e6f288a4497baaa6c2bc985c130938842","scripts":{"test":"vitest run","build":"tsup","check":"npm run typecheck && npm test && npm run build","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run check","bench:height-index":"node bench/height-index-build.mjs"},"_npmUser":{"name":"artemtutt","email":"karelov.crcur@gmail.com"},"repository":{"url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git","type":"git"},"_npmVersion":"10.9.2","description":"A low-level, chat-aware React virtualizer for dynamic message lists, infinite history, and read receipts.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.1","vitest":"^3.2.4","react-dom":"^19.1.1","typescript":"^5.9.2","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@testing-library/react":"^16.3.0"},"peerDependencies":{"react":">=18.0.0"},"_npmOperationalInternal":{"tmp":"tmp/use-chat-virtualizer_0.2.0_1786478733313_0.49271221872948123","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@artemtutt/use-chat-virtualizer","version":"0.2.1","keywords":["react","chat","virtualization","virtual-list","messages"],"author":{"name":"ArtemTutt"},"license":"MIT","_id":"@artemtutt/use-chat-virtualizer@0.2.1","maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"dist":{"shasum":"c19ff439fa0db94e1bc322ca2673e9e5102b4709","tarball":"https://registry.npmjs.org/@artemtutt/use-chat-virtualizer/-/use-chat-virtualizer-0.2.1.tgz","fileCount":9,"integrity":"sha512-mI9+5SvOluYGVrI44X6swur1d8IlEW1vvj/TfaAZHCYPBlKkzCaxjvzLlFDQAZ1DjWjtDxTbYaeo87yRrRRL2Q==","signatures":[{"sig":"MEUCIG6uoiIjD+LYQxbIDctg1ABbDv3NamGdCmNlFyCl6ZJ5AiEAj5auDaVfAbx/cJrzlM9KnCLcccbi+XPndgEzucdG+kc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":205888},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"8e6e4a3e7317fafa23754c95eb8e5bfad535e940","scripts":{"test":"vitest run","build":"tsup","check":"npm run typecheck && npm test && npm run build","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run check","bench:height-index":"node bench/height-index-build.mjs"},"_npmUser":{"name":"artemtutt","email":"karelov.crcur@gmail.com"},"repository":{"url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git","type":"git"},"_npmVersion":"10.9.2","description":"A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.1","vitest":"^3.2.4","react-dom":"^19.1.1","typescript":"^5.9.2","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@testing-library/react":"^16.3.0"},"peerDependencies":{"react":">=18.0.0"},"_npmOperationalInternal":{"tmp":"tmp/use-chat-virtualizer_0.2.1_1786479384166_0.6154087121529208","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@artemtutt/use-chat-virtualizer","version":"0.3.0","keywords":["react","chat","virtualization","virtual-list","messages"],"author":{"name":"ArtemTutt"},"license":"MIT","_id":"@artemtutt/use-chat-virtualizer@0.3.0","maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"dist":{"shasum":"eb52d76a39c04f2e166fc962b98533606f7bdd0a","tarball":"https://registry.npmjs.org/@artemtutt/use-chat-virtualizer/-/use-chat-virtualizer-0.3.0.tgz","fileCount":9,"integrity":"sha512-ye0Qjru1ZxUDQoNRpnGmYxytx0/JkV2C61wzooq375eRbEVn90VO81nZ7eey93NgzcsY0oeJPPJsqq65QEKKcw==","signatures":[{"sig":"MEUCIQCXjeNADTEN+nvgnID15cNvIaECg1VJXvB4ML7aJaQnIwIgW2SIMtUVgZBa9TK0u/CsmfL0hgo293Ag4VEB73i9qe8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":224733},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"a9aa21ef7cbd49c37ef8aa46f2c3e08618361be6","scripts":{"test":"vitest run","build":"tsup","check":"npm run typecheck && npm test && npm run build","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run check","bench:height-index":"node bench/height-index-build.mjs"},"_npmUser":{"name":"artemtutt","email":"karelov.crcur@gmail.com"},"repository":{"url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git","type":"git"},"_npmVersion":"10.9.2","description":"A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.1","vitest":"^3.2.4","react-dom":"^19.1.1","typescript":"^5.9.2","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@testing-library/react":"^16.3.0"},"peerDependencies":{"react":">=18.0.0"},"_npmOperationalInternal":{"tmp":"tmp/use-chat-virtualizer_0.3.0_1786824270944_0.5316003000200642","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@artemtutt/use-chat-virtualizer","version":"1.0.0","description":"A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.","author":{"name":"ArtemTutt"},"keywords":["react","chat","virtualization","virtual-list","messages"],"license":"MIT","homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","repository":{"type":"git","url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git"},"bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"type":"module","sideEffects":false,"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"scripts":{"build":"tsup","check":"npm run typecheck && npm test && npm run build","bench:height-index":"node bench/height-index-build.mjs","prepublishOnly":"npm run check","test":"vitest run","test:watch":"vitest","test:e2e":"playwright test","test:e2e:ui":"playwright test --ui","test:e2e:headed":"playwright test --headed","dev:browser":"vite --config vite.browser.config.ts","typecheck":"tsc --noEmit","version-packages":"changeset version"},"peerDependencies":{"react":">=18.0.0"},"devDependencies":{"@changesets/cli":"^3.0.0","@playwright/test":"^1.62.1","@testing-library/react":"^16.3.0","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@vitejs/plugin-react":"^5.2.0","jsdom":"^26.1.0","react":"^19.1.1","react-dom":"^19.1.1","tsup":"^8.5.0","typescript":"^5.9.2","vite":"^7.3.6","vitest":"^3.2.4"},"engines":{"node":">=18"},"publishConfig":{"access":"public"},"_id":"@artemtutt/use-chat-virtualizer@1.0.0","gitHead":"47c0d7b49bf2a3b5997159374a3ab1f1c2a339cf","_nodeVersion":"22.17.1","_npmVersion":"10.9.2","dist":{"integrity":"sha512-AKq01eUuD/XyGf6VM2gyg+rgK0McIrc5dlHPBIMtmiJu4ZsQYc8zf+ikwWkuu6myrEf2M7tlzQ4b1C3nH04uMg==","shasum":"12164574f6e40673a5c13ea54abc4eb12577d69f","tarball":"https://registry.npmjs.org/@artemtutt/use-chat-virtualizer/-/use-chat-virtualizer-1.0.0.tgz","fileCount":9,"unpackedSize":243908,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCX16KRujbEc1CJuHmUcS1sqGqGPRYUkY4ofdXQe6haBgIhAKfF/k7HiEjYZWgmv1T6nTcBRbZ4fhpQiQYCtdmfMTxP"}]},"_npmUser":{"name":"artemtutt","email":"karelov.crcur@gmail.com"},"directories":{},"maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/use-chat-virtualizer_1.0.0_1786900418008_0.11270528488115117"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-07T07:27:38.466Z","modified":"2026-08-16T17:13:38.270Z","0.1.0":"2026-08-07T07:27:38.718Z","0.2.0":"2026-08-11T20:05:33.480Z","0.2.1":"2026-08-11T20:16:24.337Z","0.3.0":"2026-08-15T20:04:31.097Z","1.0.0":"2026-08-16T17:13:38.130Z"},"bugs":{"url":"https://github.com/ArtemTutt/use-chat-virtualizer/issues"},"author":{"name":"ArtemTutt"},"license":"MIT","homepage":"https://github.com/ArtemTutt/use-chat-virtualizer#readme","keywords":["react","chat","virtualization","virtual-list","messages"],"repository":{"type":"git","url":"git+https://github.com/ArtemTutt/use-chat-virtualizer.git"},"description":"A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.","maintainers":[{"name":"artemtutt","email":"karelov.crcur@gmail.com"}],"readme":"# @artemtutt/use-chat-virtualizer\n\nA low-level, chat-aware React virtualizer for message lists with dynamic row\nheights. It keeps your DOM and styling under application control while handling\nthe awkward scroll geometry that is specific to chats. It includes built-in\nscroll tracking and virtual `gap`/edge-padding geometry, so no wrapper scroll\nhandler or CSS spacing workaround is required.\n\n## Why\n\n- dynamic heights measured with `ResizeObserver`;\n- one shared observer for the container and all mounted rows;\n- automatic passive scroll subscription — no `onScroll={reportScroll}` needed;\n- predictable row-count or pixel-based overscan; pixel mode is suited to\n  dynamically measured messages;\n- built-in row gaps and start/end padding, included in virtual offsets and\n  `totalHeight`;\n- prepend history without moving the visible message;\n- edge-triggered loading of older history near the start of a chat;\n- exact, non-overscanned visible row keys for read receipts;\n- bottom pinning only while the user is near the bottom;\n- height-change compensation for rows above the viewport;\n- jump to a row that is not currently mounted;\n- O(n) Fenwick-tree construction, with O(log n) height updates, offset lookups,\n  and mounted-window boundary searches;\n- a framework-independent core with batched layout transactions;\n- no message schema, markup, or styling imposed by the package.\n\n## Tested where chat UIs actually break\n\nVirtualizers can look correct in DOM mocks yet fail when a browser lays out a\nreal message, loads media, or clamps a scroll position. This package combines\nfast unit tests with browser-level layout tests in Chromium, Firefox, and\nWebKit.\n\n![Older history is prepended while message #20 keeps its screen position](docs/prepend-anchor.gif)\n\nThe browser suite verifies the scroll-sensitive behavior users notice:\n\n- opens a populated chat at the real bottom while mounting only a window;\n- preserves a message's measured screen position when history is prepended;\n- stays pinned to the bottom as streaming content or delayed media changes a\n  row's height;\n- recalculates correctly when the chat viewport is resized;\n- responds to native user scrolling, rather than a simulated scroll value.\n\nThese assertions compare `scrollTop` and the anchor row's real\n`getBoundingClientRect()` with a small pixel tolerance. They are geometry\nchecks, not screenshots alone. Every push and pull request runs the same suite\nin CI, with traces, screenshots, and video retained when a test fails.\n\n## Install\n\n```bash\nnpm install @artemtutt/use-chat-virtualizer\n```\n\nReact 18 or newer is required as a peer dependency.\n\n## Run the browser layout suite\n\nVitest covers the core and React adapter with deterministic DOM mocks. Browser\ntests cover real layout, native scrolling, and `ResizeObserver` behavior in\nChromium, Firefox, and WebKit.\n\n```bash\nnpx playwright install\nnpm run test:e2e\n```\n\nTo run a single engine, append for example `-- --project=chromium`. Use\n`npm run test:e2e:headed` or `npm run test:e2e:ui` while debugging.\n\n## Compatibility\n\nThe published package includes ESM, CommonJS, and TypeScript declarations. It\nhas been verified as a real package consumer with:\n\n- React `18.3.1` and React `19.2.8`;\n- strict TypeScript compilation with both React 18 and React 19 types;\n- React 18 server rendering;\n- Next.js `16.3.0` App Router and Turbopack production builds;\n- real browser layout and scroll behavior in Chromium, Firefox, and WebKit.\n\nNo Next.js `transpilePackages` configuration or client-only dynamic import is\nrequired. The React adapter requires a browser with `ResizeObserver`; the\nframework-independent `ChatVirtualizerCore` does not depend on the DOM.\n\n## Basic usage\n\n```tsx\nimport { useCallback, useEffect, useRef } from 'react';\nimport { useChatVirtualizer } from '@artemtutt/use-chat-virtualizer';\n\nfunction MessageList({ chatId, messages }) {\n  const scrollElementRef = useRef<HTMLDivElement>(null);\n  const loadPreviousMessages = useCallback(() => {\n    // Fetch and prepend the next older page to `messages` in application state.\n    // The virtualizer preserves the visible-message anchor after the prepend.\n  }, [chatId]);\n  const virtualizer = useChatVirtualizer({\n    chatId,\n    rows: messages,\n    scrollElementRef,\n    getRowKey: (message) => message.id,\n    estimateRowHeight: (message) => estimateMessageHeight(message),\n    getMeasurementVersion: (message) => message.layoutVersion,\n    // Recommended for dynamic-height chat messages.\n    overscanPx: 600,\n    gap: 8,\n    paddingStart: 12,\n    paddingEnd: 12,\n    followOutput: 'auto',\n    onStartReached: loadPreviousMessages,\n    startReachedThreshold: 300,\n  });\n\n  useEffect(() => {\n    if (virtualizer.lastVisibleKey) {\n      markMessagesReadThrough(virtualizer.lastVisibleKey);\n    }\n  }, [virtualizer.lastVisibleKey]);\n\n  return (\n    <div\n      ref={scrollElementRef}\n      className=\"scrollContainer\"\n    >\n      <div\n        className=\"virtualTrack\"\n        style={{ height: virtualizer.totalHeight }}\n      >\n        {virtualizer.virtualItems.map((item) => (\n          <div\n            key={item.key}\n            ref={virtualizer.getMeasureRef(item.key)}\n            className=\"virtualRow\"\n            style={{ top: item.top }}\n          >\n            {renderMessage(item.row)}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n```\n\n```css\n.scrollContainer {\n  overflow-y: auto;\n  overflow-anchor: none;\n  overscroll-behavior-y: contain;\n  position: relative;\n}\n\n.virtualTrack {\n  position: relative;\n  width: 100%;\n}\n\n.virtualRow {\n  left: 0;\n  position: absolute;\n  right: 0;\n  width: 100%;\n}\n```\n\nThe hook subscribes to the scrolling element itself, so no JSX `onScroll` is\nrequired. `reportScroll` remains available for manual integrations.\n\nUse `gap`, `paddingStart`, and `paddingEnd` for virtual spacing. `gap` exists\nonly between rows; both paddings are included in `totalHeight`. Do not also add\nCSS flex/grid `gap` or row margins that are not included in the measured row\nheight, because that would make DOM geometry differ from virtual offsets. A\nnegative, `NaN`, or infinite spacing value is normalized to `0`.\n\n### Choosing overscan\n\n`overscan` preserves the original row-count behavior: it mounts a fixed number\nof extra rows on each side of the viewport.\n\n```tsx\nuseChatVirtualizer({\n  // …required options\n  overscan: 10,\n});\n```\n\nFor dynamic chat rows, prefer `overscanPx`. It mounts rows intersecting the\nviewport expanded by this many CSS pixels above and below, so a tall image or a\nshort text message does not make the pre-render distance unpredictable.\n\n```tsx\nuseChatVirtualizer({\n  // …required options\n  overscanPx: 600,\n});\n```\n\nIf both are present, `overscanPx` wins — including `overscanPx: 0` — and the\nvalues are never combined. Changing `overscanPx` at runtime updates only the\nmounted window; it does not reset scroll anchoring, follow-output behavior, or\nthe `onStartReached` edge state.\n\n## Next.js App Router\n\nThe component that calls `useChatVirtualizer` must be a Client Component. Add\n`'use client'` before imports in that file. A Server Component can fetch the\nmessages and pass serializable rows into it.\n\n```tsx\n// app/chat/page.tsx — Server Component\nimport { Chat } from './chat';\n\nexport default async function ChatPage() {\n  const messages = await loadMessages();\n  return <Chat chatId=\"support\" messages={messages} />;\n}\n```\n\n```tsx\n// app/chat/chat.tsx — Client Component\n'use client';\n\nimport { useRef } from 'react';\nimport { useChatVirtualizer } from '@artemtutt/use-chat-virtualizer';\n\ntype Message = {\n  id: string;\n  text: string;\n};\n\nexport function Chat({\n  chatId,\n  messages,\n}: {\n  chatId: string;\n  messages: Message[];\n}) {\n  const scrollElementRef = useRef<HTMLDivElement>(null);\n  const virtualizer = useChatVirtualizer({\n    chatId,\n    rows: messages,\n    scrollElementRef,\n    getRowKey: (message) => message.id,\n    estimateRowHeight: () => 56,\n  });\n\n  return (\n    <div\n      ref={scrollElementRef}\n      style={{\n        height: 600,\n        overflowY: 'auto',\n        overflowAnchor: 'none',\n        position: 'relative',\n      }}\n    >\n      <div style={{ height: virtualizer.totalHeight, position: 'relative' }}>\n        {virtualizer.virtualItems.map((item) => (\n          <div\n            key={item.key}\n            ref={virtualizer.getMeasureRef(item.key)}\n            style={{\n              left: 0,\n              position: 'absolute',\n              right: 0,\n              top: item.top,\n            }}\n          >\n            {item.row.text}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n```\n\nThe hook is safe to import during server rendering, but it must be called from\na Client Component in the App Router because it uses React state, effects,\nevent handlers, and browser measurement after hydration. The Pages Router does\nnot require the `'use client'` directive.\n\n## API\n\n### `useChatVirtualizer(options)`\n\nRequired options:\n\n- `rows` — your flat list of renderable rows;\n- `getRowKey` — returns a stable, unique string key;\n- `estimateRowHeight` — returns the initial height before measurement;\n- `scrollElementRef` — ref of the scrolling element.\n\nOptional options:\n\n- `chatId` — resets measurements, observers, anchors, and initial position when\n  switching conversations;\n- `overscan` — extra rows mounted on each side, default `10`. This is the\n  legacy row-count mode and remains unchanged when `overscanPx` is omitted;\n- `overscanPx` — extra CSS pixels mounted above and below the viewport. This is\n  recommended for dynamic-height chat rows, where a fixed number of rows can\n  represent wildly different distances. When it is provided, including `0`, it\n  takes precedence over `overscan`; the two values are never added together.\n  `0`, negative, `NaN`, and infinite values result in no extra pixel window;\n- `gap` — pixels between adjacent rows; it is never added after the final row;\n- `paddingStart` / `paddingEnd` — pixels before the first and after the last\n  row, both included in `totalHeight`; invalid spacing values become `0`;\n- `atBottomThreshold` — bottom proximity in pixels, default `96`;\n- `onStartReached` — asks the application to load an older page when a\n  non-empty list enters the start threshold. The library never fetches or\n  mutates messages itself;\n- `startReachedThreshold` — start proximity in pixels, default `300`. Negative\n  values become `0`; non-finite values use the default;\n- `initialScroll` — `bottom` (default) or `top`;\n- `getMeasurementVersion` — invalidates a cached height when a row keeps its key\n  but changes layout substantially;\n- `followOutput` — `false`, `auto`, `smooth`, or a policy function deciding how\n  appended rows should be followed;\n- `onAtBottomChange` — notification when the derived bottom state changes.\n\nThe hook returns:\n\n- `virtualItems` and `totalHeight` for rendering;\n- `getMeasureRef(key)` for a stable measurement ref;\n- `measureElement(key, node)` when manual ref management is preferred;\n- `reportScroll()` for manual scroll-event integrations (automatic subscription\n  is enabled by default);\n- `scrollToKey`, `scrollToIndex`, and `scrollToBottom`;\n- `getOffsetForKey`;\n- `isAtBottom`.\n- `visibleRange`, whose `startIndex` is inclusive and `endIndex` is exclusive;\n  unlike `virtualItems`, this range never includes row or pixel overscan;\n- `firstVisibleKey` and `lastVisibleKey`, or `null` if no row intersects the\n  viewport.\n\nBoth overscan modes use a half-open expanded viewport: a row whose top is\nexactly at its upper boundary is not mounted. Space occupied solely by virtual\n`gap` or edge padding does not produce a row.\n\n`scrollToKey` works even if the row is outside the mounted window. If the\nmessage has not been loaded yet, loading pages remains the application's\nresponsibility; call `scrollToKey` after the target row appears in `rows`.\nThe method returns `false` when the key is not loaded. Estimated jumps are\nautomatically corrected after the target receives its real measurement.\n\n### Following appended output\n\nThe default `followOutput: 'auto'` follows appended rows only when the user was\nalready near the bottom. `smooth` uses smooth scrolling under the same rule.\nApplications can distinguish their own messages with a policy:\n\n```tsx\nfollowOutput: ({ wasAtBottom }) =>\n  sentByCurrentUser ? 'smooth' : wasAtBottom ? 'auto' : false\n```\n\nFor event-specific behavior outside a rows update, call `scrollToBottom()`\ndirectly after sending.\n\n### Loading older messages and read receipts\n\n`onStartReached` is edge-triggered: it fires once when a non-empty chat enters\nthe threshold (including an initial top position), then is re-armed only after\nthe user leaves that zone and returns. It is reset when `chatId` changes. This\nprevents repeated fetch requests from renders, scrolling while already near the\ntop, or `ResizeObserver` updates. A list shorter than its viewport is inside\nthe threshold, so it produces one request per entry/chat; an empty list does\nnot request anything. The callback runs after React commits and always uses the\nlatest callback reference, so it is safe with SSR and React Strict Mode.\n\nUse the non-overscanned visible keys for read receipts rather than\n`virtualItems`, whose mounted window may include messages outside the viewport:\n\n```tsx\nconst loadPreviousMessages = useCallback(() => {\n  loadOlderPage(chatId); // update rows in your application when it resolves\n}, [chatId]);\n\nconst virtualizer = useChatVirtualizer({\n  chatId,\n  rows: messages,\n  getRowKey: (message) => message.id,\n  estimateRowHeight: estimateMessageHeight,\n  scrollElementRef,\n  onStartReached: loadPreviousMessages,\n  startReachedThreshold: 300,\n});\n\nuseEffect(() => {\n  if (virtualizer.lastVisibleKey !== null) {\n    sendReadReceipt(chatId, virtualizer.lastVisibleKey);\n  }\n}, [chatId, virtualizer.lastVisibleKey]);\n```\n\n## Prepending history\n\nNo imperative prepend call is needed. Before a rows update the hook remembers\nthe first visible row key and its position inside the viewport. After new rows\nare prepended it restores that key-based anchor. Later measurements of the new\nrows are compensated independently.\n\nIf the visible anchor row is deleted, the core selects the nearest surviving\nrow and preserves its viewport position.\n\n## Architecture\n\n`ChatVirtualizerCore` owns rows, cached measurements, the Fenwick height index,\nanchors, and pending jump intent. Its `HeightIndex` constructs its Fenwick tree\nin linear time while retaining logarithmic updates, offset lookups, and boundary\nsearches. The core has no React or DOM dependency and is exported for custom\nadapters. `useChatVirtualizer` is a thin React/DOM layer that observes the\nscroll container and rows with one shared `ResizeObserver`, subscribes to the\ncore through `useSyncExternalStore`, and applies the resulting scroll\nadjustments before paint.\n\n## Development\n\n```bash\nnpm install\nnpm run check\nnpm run bench:height-index\n```\n\nThe package builds ESM, CommonJS, source maps, and TypeScript declarations into\n`dist/`. The deterministic height-index benchmark compares the previous\nO(n log n) construction strategy with the current O(n) build for 1,000,\n10,000, and 100,000 rows. Use it to compare relative performance on your own\nmachine; absolute timings naturally vary by environment.\n","readmeFilename":"README.md"}