@@ -13,13 +13,12 @@ import {
13
13
Observable ,
14
14
Subject ,
15
15
merge ,
16
- skip ,
16
+ first ,
17
17
} from 'rxjs' ;
18
18
import {
19
19
HttpRequestState ,
20
20
httpRequestStates ,
21
21
isLoadedState ,
22
- loadingState ,
23
22
} from 'ngx-http-request-state' ;
24
23
import { BookApiResponse } from './model/book' ;
25
24
import { BookService } from './book-service/book.service' ;
@@ -37,57 +36,94 @@ const PAGE_SIZE = 5;
37
36
} )
38
37
export class InfiniteScrollerComponent {
39
38
private readonly bookService = inject ( BookService ) ;
39
+
40
+ /**
41
+ * A subject that emits when the user clicks the 'retry' button
42
+ * in the error message shown when the last request failed.
43
+ */
40
44
private readonly retry$ = new Subject < void > ( ) ;
45
+ /**
46
+ * A subject that emits true when the loading spinner at the end of the list is
47
+ * scrolled into the viewport, and false when it is scrolled out of the viewport.
48
+ */
41
49
private readonly spinnerVisible$ = new Subject < boolean > ( ) ;
42
50
43
- private readonly autoLoadMore$ = defer ( ( ) =>
51
+ /**
52
+ * The trigger to automatically load the next page of results.
53
+ *
54
+ * Emits when:
55
+ *
56
+ * - There is no inflight request
57
+ * - The previous request was successful
58
+ * - The page has been scrolled so far that the loading spinner at the end of the list is visible in the viewport
59
+ *
60
+ */
61
+ private readonly autoLoadMore$ : Observable < void > = defer ( ( ) =>
44
62
combineLatest ( [
45
63
this . spinnerVisible$ ,
46
- this . state$ . pipe ( map ( isLoadedState ) , distinctUntilChanged ( ) ) ,
64
+ this . state$ . pipe (
65
+ map ( isLoadedState ) ,
66
+ distinctUntilChanged ( ) ,
67
+ // Delay with debounceTime to allow time for the new value to render and possibly push
68
+ // the spinner out of the viewport before we emit again. Prevents an immediate second
69
+ // request until the spinner is back in the viewport after scrolling again.
70
+ debounceTime ( 100 )
71
+ ) ,
47
72
] ) . pipe (
48
- debounceTime ( 100 ) ,
49
73
filter ( ( [ spinnerVisible , isLoaded ] ) => spinnerVisible && isLoaded ) ,
50
74
map ( ( ) => undefined as void )
51
75
)
52
76
) ;
53
77
78
+ /**
79
+ * An Observable that emits when we've loaded all items (no more pages of results are available)
80
+ */
54
81
private readonly noMoreBooks$ = defer ( ( ) =>
55
82
this . state$ . pipe (
56
- skip ( 1 ) ,
57
83
filter ( isLoadedState ) ,
58
84
map ( ( { value } ) => value . numFound <= value . docs . length ) ,
59
- filter ( ( allDone ) => allDone )
85
+ filter ( ( allDone ) => allDone ) ,
86
+ first ( )
60
87
)
61
88
) ;
62
89
90
+ /**
91
+ * The data loading state.
92
+ *
93
+ * The value property of every state emitted (i.e., each of the Loaded, Loading and Error states) always contains all
94
+ * the data loaded so far. So every LoadedState contains all the previous pages loaded, including the most recent one.
95
+ */
63
96
readonly state$ : Observable < HttpRequestState < BookApiResponse > > = merge (
64
97
this . retry$ ,
65
98
this . autoLoadMore$
66
99
) . pipe (
67
100
takeUntil ( this . noMoreBooks$ ) ,
68
101
startWith ( undefined as void ) ,
102
+ // Use switchScan so we can cumulatively build up the data in the value property of each state emitted:
69
103
switchScan (
70
- ( prevState : HttpRequestState < BookApiResponse > ) =>
104
+ ( prevState : HttpRequestState < BookApiResponse > | undefined ) =>
71
105
this . bookService
72
106
. findBooks (
73
107
'Jasper Fforde' ,
74
- prevState . value ?. docs . length ?? 0 ,
108
+ prevState ? .value ?. docs . length ?? 0 ,
75
109
PAGE_SIZE
76
110
)
77
111
. pipe (
78
112
httpRequestStates ( ) ,
79
- // For a loading or error state, add the previously loaded
80
- // data value to it so it doesn't disappear from the view.
81
- //
82
- // For a loaded state, append the new value to the end of the
83
- // previous value, giving us an ever-growing list of items.
84
113
map ( ( state ) => ( {
85
114
...state ,
86
- value : mergeData ( prevState . value , state . value ) ,
115
+ // For a loading or error state, add the previously loaded
116
+ // data value to it, so it doesn't disappear from the view.
117
+ //
118
+ // For a loaded state, append the new value to the end of the
119
+ // previous value, giving us an ever-growing list of items.
120
+ value : mergeData ( prevState ?. value , state . value ) ,
87
121
} ) )
88
122
) ,
89
- loadingState < BookApiResponse > ( )
123
+ undefined
90
124
) ,
125
+ // shareReplay to prevent an infinite recursion of subscriptions
126
+ // (as this observable depends on observables that depend on this)
91
127
shareReplay ( {
92
128
bufferSize : 1 ,
93
129
refCount : true ,
0 commit comments