11'use client'
22
3- import { useEffect , useRef , useState } from 'react'
3+ import { useEffect , useState } from 'react'
44
55interface ProgressiveListOptions {
66 /** Number of items to render in the initial batch (most recent items) */
@@ -14,15 +14,31 @@ const DEFAULTS = {
1414 batchSize : 5 ,
1515} satisfies Required < ProgressiveListOptions >
1616
17+ interface ProgressiveListState {
18+ key : string
19+ count : number
20+ caughtUp : boolean
21+ }
22+
23+ function createInitialState (
24+ key : string ,
25+ itemCount : number ,
26+ initialBatch : number
27+ ) : ProgressiveListState {
28+ const count = Math . min ( itemCount , initialBatch )
29+ return {
30+ key,
31+ count,
32+ caughtUp : itemCount > 0 && count >= itemCount ,
33+ }
34+ }
35+
1736/**
1837 * Progressively renders a list of items so that first paint is fast.
1938 *
2039 * On mount (or when `key` changes), only the most recent `initialBatch`
2140 * items are rendered. The rest are added in `batchSize` increments via
22- * `requestAnimationFrame` so the browser never blocks on a large DOM mount.
23- *
24- * Once staging completes for a given key it never re-stages -- new items
25- * appended to the list are rendered immediately.
41+ * `requestAnimationFrame`.
2642 *
2743 * @param items Full list of items to render.
2844 * @param key A session/conversation identifier. When it changes,
@@ -35,67 +51,77 @@ export function useProgressiveList<T>(
3551 key : string ,
3652 options ?: ProgressiveListOptions
3753) : { staged : T [ ] ; isStaging : boolean } {
38- const initialBatch = options ?. initialBatch ?? DEFAULTS . initialBatch
39- const batchSize = options ?. batchSize ?? DEFAULTS . batchSize
54+ const initialBatch = Math . max ( 0 , options ?. initialBatch ?? DEFAULTS . initialBatch )
55+ const batchSize = Math . max ( 1 , options ?. batchSize ?? DEFAULTS . batchSize )
56+ const [ state , setState ] = useState ( ( ) => createInitialState ( key , items . length , initialBatch ) )
4057
41- const completedKeysRef = useRef ( new Set < string > ( ) )
42- const prevKeyRef = useRef ( key )
43- const stagingCountRef = useRef ( initialBatch )
44- const [ count , setCount ] = useState ( ( ) => {
45- if ( items . length <= initialBatch ) return items . length
46- return initialBatch
47- } )
58+ const renderState =
59+ state . key === key && ( state . count > 0 || items . length === 0 || state . caughtUp )
60+ ? state
61+ : createInitialState ( key , items . length , initialBatch )
4862
4963 useEffect ( ( ) => {
50- if ( completedKeysRef . current . has ( key ) ) {
51- setCount ( items . length )
52- return
53- }
64+ setState ( ( prev ) => {
65+ if ( prev . key !== key ) {
66+ return createInitialState ( key , items . length , initialBatch )
67+ }
5468
55- if ( items . length <= initialBatch ) {
56- setCount ( items . length )
57- completedKeysRef . current . add ( key )
58- return
59- }
69+ if ( items . length === 0 ) {
70+ if ( prev . count === 0 && ! prev . caughtUp ) {
71+ return prev
72+ }
73+ return { key, count : 0 , caughtUp : false }
74+ }
6075
61- let current = Math . max ( stagingCountRef . current , initialBatch )
62- setCount ( current )
76+ if ( prev . caughtUp ) {
77+ if ( prev . count === items . length ) {
78+ return prev
79+ }
80+ return { key, count : items . length , caughtUp : true }
81+ }
6382
64- let frame : number | undefined
83+ const minimumCount = Math . min ( items . length , initialBatch )
84+ if ( prev . count >= minimumCount && prev . count <= items . length ) {
85+ return prev
86+ }
6587
66- const step = ( ) => {
67- const total = items . length
68- current = Math . min ( total , current + batchSize )
69- stagingCountRef . current = current
70- setCount ( current )
71- if ( current >= total ) {
72- completedKeysRef . current . add ( key )
73- frame = undefined
74- return
88+ const count = Math . min ( items . length , Math . max ( prev . count , minimumCount ) )
89+ return {
90+ key,
91+ count,
92+ caughtUp : count >= items . length ,
7593 }
76- frame = requestAnimationFrame ( step )
94+ } )
95+ } , [ key , items . length , initialBatch ] )
96+
97+ useEffect ( ( ) => {
98+ if ( state . key !== key || state . caughtUp || state . count >= items . length ) {
99+ return
77100 }
78101
79- frame = requestAnimationFrame ( step )
102+ const frame = requestAnimationFrame ( ( ) => {
103+ setState ( ( prev ) => {
104+ if ( prev . key !== key || prev . caughtUp ) {
105+ return prev
106+ }
80107
81- return ( ) => {
82- if ( frame !== undefined ) cancelAnimationFrame ( frame )
83- }
84- } , [ key , items . length , initialBatch , batchSize ] )
108+ const count = Math . min ( items . length , prev . count + batchSize )
109+ return {
110+ key,
111+ count,
112+ caughtUp : count >= items . length ,
113+ }
114+ } )
115+ } )
85116
86- let effectiveCount = count
87- if ( prevKeyRef . current !== key ) {
88- effectiveCount = items . length <= initialBatch ? items . length : initialBatch
89- stagingCountRef . current = initialBatch
90- }
91- prevKeyRef . current = key
92-
93- const isCompleted = completedKeysRef . current . has ( key )
94- const isStaging = ! isCompleted && effectiveCount < items . length
95- const staged =
96- isCompleted || effectiveCount >= items . length
97- ? items
98- : items . slice ( Math . max ( 0 , items . length - effectiveCount ) )
117+ return ( ) => cancelAnimationFrame ( frame )
118+ } , [ state . key , state . count , state . caughtUp , key , items . length , batchSize ] )
119+
120+ const effectiveCount = renderState . caughtUp
121+ ? items . length
122+ : Math . min ( renderState . count , items . length )
123+ const staged = items . slice ( Math . max ( 0 , items . length - effectiveCount ) )
124+ const isStaging = effectiveCount < items . length
99125
100126 return { staged, isStaging }
101127}
0 commit comments