11'use client'
22
3- import { useEffect , useRef , useState } from 'react'
3+ import { useEffect , useLayoutEffect , useRef , 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,83 @@ 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 ) )
57+ const latestItemCountRef = useRef ( items . length )
58+
59+ useLayoutEffect ( ( ) => {
60+ latestItemCountRef . current = items . length
61+ } , [ items . length ] )
4062
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- } )
63+ const renderState =
64+ state . key === key && ( state . count > 0 || items . length === 0 || state . caughtUp )
65+ ? state
66+ : createInitialState ( key , items . length , initialBatch )
4867
4968 useEffect ( ( ) => {
50- if ( completedKeysRef . current . has ( key ) ) {
51- setCount ( items . length )
52- return
53- }
69+ setState ( ( prev ) => {
70+ if ( prev . key !== key ) {
71+ return createInitialState ( key , items . length , initialBatch )
72+ }
5473
55- if ( items . length <= initialBatch ) {
56- setCount ( items . length )
57- completedKeysRef . current . add ( key )
58- return
59- }
74+ if ( items . length === 0 ) {
75+ if ( prev . count === 0 && ! prev . caughtUp ) {
76+ return prev
77+ }
78+ return { key, count : 0 , caughtUp : false }
79+ }
6080
61- let current = Math . max ( stagingCountRef . current , initialBatch )
62- setCount ( current )
81+ if ( prev . caughtUp ) {
82+ if ( prev . count === items . length ) {
83+ return prev
84+ }
85+ return { key, count : items . length , caughtUp : true }
86+ }
6387
64- let frame : number | undefined
88+ const minimumCount = Math . min ( items . length , initialBatch )
89+ if ( prev . count >= minimumCount && prev . count <= items . length ) {
90+ return prev
91+ }
6592
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
93+ const count = Math . min ( items . length , Math . max ( prev . count , minimumCount ) )
94+ return {
95+ key,
96+ count,
97+ caughtUp : count >= items . length ,
7598 }
76- frame = requestAnimationFrame ( step )
99+ } )
100+ } , [ key , items . length , initialBatch ] )
101+
102+ useEffect ( ( ) => {
103+ if ( state . key !== key || state . caughtUp || state . count >= items . length ) {
104+ return
77105 }
78106
79- frame = requestAnimationFrame ( step )
107+ const frame = requestAnimationFrame ( ( ) => {
108+ setState ( ( prev ) => {
109+ if ( prev . key !== key || prev . caughtUp ) {
110+ return prev
111+ }
80112
81- return ( ) => {
82- if ( frame !== undefined ) cancelAnimationFrame ( frame )
83- }
84- } , [ key , items . length , initialBatch , batchSize ] )
113+ const itemCount = latestItemCountRef . current
114+ const count = Math . min ( itemCount , prev . count + batchSize )
115+ return {
116+ key,
117+ count,
118+ caughtUp : count >= itemCount ,
119+ }
120+ } )
121+ } )
85122
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 ) )
123+ return ( ) => cancelAnimationFrame ( frame )
124+ } , [ state . key , state . count , state . caughtUp , key , items . length , batchSize ] )
125+
126+ const effectiveCount = renderState . caughtUp
127+ ? items . length
128+ : Math . min ( renderState . count , items . length )
129+ const staged = items . slice ( Math . max ( 0 , items . length - effectiveCount ) )
130+ const isStaging = effectiveCount < items . length
99131
100132 return { staged, isStaging }
101133}
0 commit comments