@wyxos/vibe 1.6.25 → 1.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,342 +1,465 @@
1
- import { ref, type Ref } from 'vue'
2
- import { normalizeError } from './utils/errorHandler'
3
-
4
- export interface UseMasonryPaginationOptions {
5
- getNextPage: (page: any) => Promise<{ items: any[]; nextPage: any }>
6
- masonry: Ref<any[]>
7
- isLoading: Ref<boolean>
8
- hasReachedEnd: Ref<boolean>
9
- loadError: Ref<Error | null>
10
- currentPage: Ref<any>
11
- paginationHistory: Ref<any[]>
12
- refreshLayout: (items: any[]) => void
13
- retryMaxAttempts: number
14
- retryInitialDelayMs: number
15
- retryBackoffStepMs: number
16
- backfillEnabled: boolean
17
- backfillDelayMs: number
18
- backfillMaxCalls: number
19
- pageSize: number
20
- autoRefreshOnEmpty: boolean
21
- emits: {
22
- (event: 'retry:start', payload: { attempt: number; max: number; totalMs: number }): void
23
- (event: 'retry:tick', payload: { attempt: number; remainingMs: number; totalMs: number }): void
24
- (event: 'retry:stop', payload: { attempt: number; success: boolean }): void
25
- (event: 'backfill:start', payload: { target: number; fetched: number; calls: number }): void
26
- (event: 'backfill:tick', payload: { fetched: number; target: number; calls: number; remainingMs: number; totalMs: number }): void
27
- (event: 'backfill:stop', payload: { fetched: number; calls: number; cancelled?: boolean }): void
28
- }
29
- }
30
-
31
- export function useMasonryPagination(options: UseMasonryPaginationOptions) {
32
- const {
33
- getNextPage,
34
- masonry,
35
- isLoading,
36
- hasReachedEnd,
37
- loadError,
38
- currentPage,
39
- paginationHistory,
40
- refreshLayout,
41
- retryMaxAttempts,
42
- retryInitialDelayMs,
43
- retryBackoffStepMs,
44
- backfillEnabled,
45
- backfillDelayMs,
46
- backfillMaxCalls,
47
- pageSize,
48
- autoRefreshOnEmpty,
49
- emits
50
- } = options
51
-
52
- const cancelRequested = ref(false)
53
- let backfillActive = false
54
-
55
- function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
56
- return new Promise<void>((resolve) => {
57
- const total = Math.max(0, totalMs | 0)
58
- const start = Date.now()
59
- onTick(total, total)
60
- const id = setInterval(() => {
61
- // Check for cancellation
62
- if (cancelRequested.value) {
63
- clearInterval(id)
64
- resolve()
65
- return
66
- }
67
- const elapsed = Date.now() - start
68
- const remaining = Math.max(0, total - elapsed)
69
- onTick(remaining, total)
70
- if (remaining <= 0) {
71
- clearInterval(id)
72
- resolve()
73
- }
74
- }, 100)
75
- })
76
- }
77
-
78
- async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
79
- let attempt = 0
80
- const max = retryMaxAttempts
81
- let delay = retryInitialDelayMs
82
- // eslint-disable-next-line no-constant-condition
83
- while (true) {
84
- try {
85
- const res = await fn()
86
- if (attempt > 0) {
87
- emits('retry:stop', { attempt, success: true })
88
- }
89
- return res
90
- } catch (err) {
91
- attempt++
92
- if (attempt > max) {
93
- emits('retry:stop', { attempt: attempt - 1, success: false })
94
- throw err
95
- }
96
- emits('retry:start', { attempt, max, totalMs: delay })
97
- await waitWithProgress(delay, (remaining, total) => {
98
- emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
99
- })
100
- delay += retryBackoffStepMs
101
- }
102
- }
103
- }
104
-
105
- async function getContent(page: number) {
106
- try {
107
- const response = await fetchWithRetry(() => getNextPage(page))
108
- refreshLayout([...masonry.value, ...response.items])
109
- return response
110
- } catch (error) {
111
- // Error is handled by callers (loadPage, loadNext, etc.) which set loadError
112
- throw error
113
- }
114
- }
115
-
116
- async function maybeBackfillToTarget(baselineCount: number, force = false) {
117
- if (!force && !backfillEnabled) return
118
- if (backfillActive) return
119
- if (cancelRequested.value) return
120
- // Don't backfill if we've reached the end
121
- if (hasReachedEnd.value) return
122
-
123
- const targetCount = (baselineCount || 0) + (pageSize || 0)
124
- if (!pageSize || pageSize <= 0) return
125
-
126
- const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
127
- if (lastNext == null) {
128
- hasReachedEnd.value = true
129
- return
130
- }
131
-
132
- if (masonry.value.length >= targetCount) return
133
-
134
- backfillActive = true
135
- // Set loading to true at the start of backfill and keep it true throughout
136
- isLoading.value = true
137
- try {
138
- let calls = 0
139
- emits('backfill:start', { target: targetCount, fetched: masonry.value.length, calls })
140
-
141
- while (
142
- masonry.value.length < targetCount &&
143
- calls < backfillMaxCalls &&
144
- paginationHistory.value[paginationHistory.value.length - 1] != null &&
145
- !cancelRequested.value &&
146
- !hasReachedEnd.value &&
147
- backfillActive
148
- ) {
149
- await waitWithProgress(backfillDelayMs, (remaining, total) => {
150
- emits('backfill:tick', {
151
- fetched: masonry.value.length,
152
- target: targetCount,
153
- calls,
154
- remainingMs: remaining,
155
- totalMs: total
156
- })
157
- })
158
-
159
- if (cancelRequested.value || !backfillActive) break
160
-
161
- const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
162
- if (currentPage == null) {
163
- hasReachedEnd.value = true
164
- break
165
- }
166
- try {
167
- // Don't toggle isLoading here - keep it true throughout backfill
168
- // Check cancellation before starting getContent to avoid unnecessary requests
169
- if (cancelRequested.value || !backfillActive) break
170
- const response = await getContent(currentPage)
171
- if (cancelRequested.value || !backfillActive) break
172
- // Clear error on successful load
173
- loadError.value = null
174
- paginationHistory.value.push(response.nextPage)
175
- // Update hasReachedEnd if nextPage is null
176
- if (response.nextPage == null) {
177
- hasReachedEnd.value = true
178
- }
179
- } catch (error) {
180
- // Set load error but don't break the backfill loop unless cancelled
181
- if (cancelRequested.value || !backfillActive) break
182
- loadError.value = normalizeError(error)
183
- }
184
-
185
- calls++
186
- }
187
-
188
- emits('backfill:stop', { fetched: masonry.value.length, calls })
189
- } finally {
190
- backfillActive = false
191
- // Only set loading to false when backfill completes or is cancelled
192
- isLoading.value = false
193
- }
194
- }
195
-
196
- async function loadPage(page: number) {
197
- if (isLoading.value) return
198
- // Starting a new load should clear any previous cancel request
199
- cancelRequested.value = false
200
- isLoading.value = true
201
- // Reset hasReachedEnd and loadError when loading a new page
202
- hasReachedEnd.value = false
203
- loadError.value = null
204
- try {
205
- const baseline = masonry.value.length
206
- if (cancelRequested.value) return
207
- const response = await getContent(page)
208
- if (cancelRequested.value) return
209
- // Clear error on successful load
210
- loadError.value = null
211
- currentPage.value = page // Track the current page
212
- paginationHistory.value.push(response.nextPage)
213
- // Update hasReachedEnd if nextPage is null
214
- if (response.nextPage == null) {
215
- hasReachedEnd.value = true
216
- }
217
- await maybeBackfillToTarget(baseline)
218
- return response
219
- } catch (error) {
220
- // Set load error - error is handled and exposed to UI via loadError
221
- loadError.value = normalizeError(error)
222
- throw error
223
- } finally {
224
- isLoading.value = false
225
- }
226
- }
227
-
228
- async function loadNext() {
229
- if (isLoading.value) return
230
- // Don't load if we've already reached the end
231
- if (hasReachedEnd.value) return
232
- // Starting a new load should clear any previous cancel request
233
- cancelRequested.value = false
234
- isLoading.value = true
235
- // Clear error when attempting to load
236
- loadError.value = null
237
- try {
238
- const baseline = masonry.value.length
239
- if (cancelRequested.value) return
240
- const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
241
- // Don't load if nextPageToLoad is null
242
- if (nextPageToLoad == null) {
243
- hasReachedEnd.value = true
244
- isLoading.value = false
245
- return
246
- }
247
- const response = await getContent(nextPageToLoad)
248
- if (cancelRequested.value) return
249
- // Clear error on successful load
250
- loadError.value = null
251
- currentPage.value = nextPageToLoad // Track the current page
252
- paginationHistory.value.push(response.nextPage)
253
- // Update hasReachedEnd if nextPage is null
254
- if (response.nextPage == null) {
255
- hasReachedEnd.value = true
256
- }
257
- await maybeBackfillToTarget(baseline)
258
- return response
259
- } catch (error) {
260
- // Set load error - error is handled and exposed to UI via loadError
261
- loadError.value = normalizeError(error)
262
- throw error
263
- } finally {
264
- isLoading.value = false
265
- }
266
- }
267
-
268
- async function refreshCurrentPage() {
269
- if (isLoading.value) return
270
- cancelRequested.value = false
271
- isLoading.value = true
272
-
273
- try {
274
- // Use the tracked current page
275
- const pageToRefresh = currentPage.value
276
-
277
- if (pageToRefresh == null) {
278
- console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
279
- return
280
- }
281
-
282
- // Clear existing items
283
- masonry.value = []
284
- // Reset end flag when refreshing
285
- hasReachedEnd.value = false
286
- // Reset error flag when refreshing
287
- loadError.value = null
288
-
289
- // Reset pagination history to just the current page
290
- paginationHistory.value = [pageToRefresh]
291
-
292
- // Reload the current page
293
- const response = await getContent(pageToRefresh)
294
- if (cancelRequested.value) return
295
-
296
- // Clear error on successful load
297
- loadError.value = null
298
- // Update pagination state
299
- currentPage.value = pageToRefresh
300
- paginationHistory.value.push(response.nextPage)
301
- // Update hasReachedEnd if nextPage is null
302
- if (response.nextPage == null) {
303
- hasReachedEnd.value = true
304
- }
305
-
306
- // Optionally backfill if needed
307
- const baseline = masonry.value.length
308
- await maybeBackfillToTarget(baseline)
309
-
310
- return response
311
- } catch (error) {
312
- // Set load error - error is handled and exposed to UI via loadError
313
- loadError.value = normalizeError(error)
314
- throw error
315
- } finally {
316
- isLoading.value = false
317
- }
318
- }
319
-
320
- function cancelLoad() {
321
- const wasBackfilling = backfillActive
322
- cancelRequested.value = true
323
- isLoading.value = false
324
- // Set backfillActive to false to immediately stop backfilling
325
- // The backfill loop checks this flag and will exit on the next iteration
326
- backfillActive = false
327
- // If backfill was active, emit stop event immediately
328
- if (wasBackfilling) {
329
- emits('backfill:stop', { fetched: masonry.value.length, calls: 0, cancelled: true })
330
- }
331
- }
332
-
333
- return {
334
- loadPage,
335
- loadNext,
336
- refreshCurrentPage,
337
- cancelLoad,
338
- maybeBackfillToTarget,
339
- getContent
340
- }
341
- }
342
-
1
+ import { ref, type Ref } from 'vue'
2
+ import { normalizeError } from './utils/errorHandler'
3
+
4
+ export interface UseMasonryPaginationOptions {
5
+ getPage: (page: any, context?: any) => Promise<{ items: any[]; nextPage: any }>
6
+ context?: Ref<any>
7
+ masonry: Ref<any[]>
8
+ isLoading: Ref<boolean>
9
+ hasReachedEnd: Ref<boolean>
10
+ loadError: Ref<Error | null>
11
+ currentPage: Ref<any>
12
+ paginationHistory: Ref<any[]>
13
+ refreshLayout: (items: any[]) => void
14
+ retryMaxAttempts: number
15
+ retryInitialDelayMs: number
16
+ retryBackoffStepMs: number
17
+ mode: string
18
+ backfillDelayMs: number
19
+ backfillMaxCalls: number
20
+ pageSize: number
21
+ emits: {
22
+ (event: 'loading:start'): void
23
+ (event: 'retry:start', payload: { attempt: number; max: number; totalMs: number }): void
24
+ (event: 'retry:tick', payload: { attempt: number; remainingMs: number; totalMs: number }): void
25
+ (event: 'retry:stop', payload: { attempt: number; success: boolean }): void
26
+ (event: 'backfill:start', payload: { target: number; fetched: number; calls: number }): void
27
+ (event: 'backfill:tick', payload: { fetched: number; target: number; calls: number; remainingMs: number; totalMs: number }): void
28
+ (event: 'backfill:stop', payload: { fetched: number; calls: number; cancelled?: boolean }): void
29
+ (event: 'loading:stop', payload: { fetched: number }): void
30
+ }
31
+ }
32
+
33
+ export function useMasonryPagination(options: UseMasonryPaginationOptions) {
34
+ const {
35
+ getPage,
36
+ context,
37
+ masonry,
38
+ isLoading,
39
+ hasReachedEnd,
40
+ loadError,
41
+ currentPage,
42
+ paginationHistory,
43
+ refreshLayout,
44
+ retryMaxAttempts,
45
+ retryInitialDelayMs,
46
+ retryBackoffStepMs,
47
+ mode,
48
+ backfillDelayMs,
49
+ backfillMaxCalls,
50
+ pageSize,
51
+ emits
52
+ } = options
53
+
54
+ const cancelRequested = ref(false)
55
+ let backfillActive = false
56
+
57
+ // Helper function to count items for a specific page
58
+ function countItemsForPage(page: any): number {
59
+ return masonry.value.filter((item: any) => item.page === page).length
60
+ }
61
+
62
+ // Helper function to check if an item already exists in masonry
63
+ function itemExists(item: any, itemsArray?: any[]): boolean {
64
+ if (!item || item.id == null || item.page == null) return false
65
+ const itemsToCheck = itemsArray || masonry.value
66
+ return itemsToCheck.some((existing: any) => {
67
+ return existing && existing.id === item.id && existing.page === item.page
68
+ })
69
+ }
70
+
71
+ // Helper function to get only new items from a response
72
+ function getNewItems(responseItems: any[]): any[] {
73
+ if (!responseItems || responseItems.length === 0) return []
74
+ // Create a snapshot of current masonry items to avoid reactivity issues
75
+ const currentItems = [...masonry.value]
76
+ return responseItems.filter((item: any) => {
77
+ if (!item || item.id == null || item.page == null) return false
78
+ // Check if item exists by comparing id and page
79
+ const exists = currentItems.some((existing: any) => {
80
+ return existing && existing.id === item.id && existing.page === item.page
81
+ })
82
+ return !exists
83
+ })
84
+ }
85
+
86
+ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
87
+ return new Promise<void>((resolve) => {
88
+ const total = Math.max(0, totalMs | 0)
89
+ const start = Date.now()
90
+ onTick(total, total)
91
+ const id = setInterval(() => {
92
+ // Check for cancellation
93
+ if (cancelRequested.value) {
94
+ clearInterval(id)
95
+ resolve()
96
+ return
97
+ }
98
+ const elapsed = Date.now() - start
99
+ const remaining = Math.max(0, total - elapsed)
100
+ onTick(remaining, total)
101
+ if (remaining <= 0) {
102
+ clearInterval(id)
103
+ resolve()
104
+ }
105
+ }, 100)
106
+ })
107
+ }
108
+
109
+ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
110
+ let attempt = 0
111
+ const max = retryMaxAttempts
112
+ let delay = retryInitialDelayMs
113
+ // eslint-disable-next-line no-constant-condition
114
+ while (true) {
115
+ try {
116
+ const res = await fn()
117
+ if (attempt > 0) {
118
+ emits('retry:stop', { attempt, success: true })
119
+ }
120
+ return res
121
+ } catch (err) {
122
+ attempt++
123
+ if (attempt > max) {
124
+ emits('retry:stop', { attempt: attempt - 1, success: false })
125
+ throw err
126
+ }
127
+ emits('retry:start', { attempt, max, totalMs: delay })
128
+ await waitWithProgress(delay, (remaining, total) => {
129
+ emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
130
+ })
131
+ delay += retryBackoffStepMs
132
+ }
133
+ }
134
+ }
135
+
136
+ async function getContent(page: number) {
137
+ try {
138
+ const response = await fetchWithRetry(() => getPage(page, context?.value))
139
+ refreshLayout([...masonry.value, ...response.items])
140
+ return response
141
+ } catch (error) {
142
+ // Error is handled by callers (loadPage, loadNext, etc.) which set loadError
143
+ throw error
144
+ }
145
+ }
146
+
147
+ async function maybeBackfillToTarget(baselineCount: number, force = false) {
148
+ if (!force && mode !== 'backfill') return
149
+ if (backfillActive) return
150
+ if (cancelRequested.value) return
151
+ // Don't backfill if we've reached the end
152
+ if (hasReachedEnd.value) return
153
+
154
+ const targetCount = (baselineCount || 0) + (pageSize || 0)
155
+ if (!pageSize || pageSize <= 0) return
156
+
157
+ const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
158
+ if (lastNext == null) {
159
+ hasReachedEnd.value = true
160
+ return
161
+ }
162
+
163
+ if (masonry.value.length >= targetCount) return
164
+
165
+ backfillActive = true
166
+ // Set loading to true at the start of backfill and keep it true throughout
167
+ if (!isLoading.value) {
168
+ isLoading.value = true
169
+ emits('loading:start')
170
+ }
171
+ try {
172
+ let calls = 0
173
+ emits('backfill:start', { target: targetCount, fetched: masonry.value.length, calls })
174
+
175
+ while (
176
+ masonry.value.length < targetCount &&
177
+ calls < backfillMaxCalls &&
178
+ paginationHistory.value[paginationHistory.value.length - 1] != null &&
179
+ !cancelRequested.value &&
180
+ !hasReachedEnd.value &&
181
+ backfillActive
182
+ ) {
183
+ await waitWithProgress(backfillDelayMs, (remaining, total) => {
184
+ emits('backfill:tick', {
185
+ fetched: masonry.value.length,
186
+ target: targetCount,
187
+ calls,
188
+ remainingMs: remaining,
189
+ totalMs: total
190
+ })
191
+ })
192
+
193
+ if (cancelRequested.value || !backfillActive) break
194
+
195
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
196
+ if (currentPage == null) {
197
+ hasReachedEnd.value = true
198
+ break
199
+ }
200
+ try {
201
+ // Don't toggle isLoading here - keep it true throughout backfill
202
+ // Check cancellation before starting getContent to avoid unnecessary requests
203
+ if (cancelRequested.value || !backfillActive) break
204
+ const response = await getContent(currentPage)
205
+ if (cancelRequested.value || !backfillActive) break
206
+ // Clear error on successful load
207
+ loadError.value = null
208
+ paginationHistory.value.push(response.nextPage)
209
+ // Update hasReachedEnd if nextPage is null
210
+ if (response.nextPage == null) {
211
+ hasReachedEnd.value = true
212
+ }
213
+ } catch (error) {
214
+ // Set load error but don't break the backfill loop unless cancelled
215
+ if (cancelRequested.value || !backfillActive) break
216
+ loadError.value = normalizeError(error)
217
+ }
218
+
219
+ calls++
220
+ }
221
+
222
+ emits('backfill:stop', { fetched: masonry.value.length, calls })
223
+ } finally {
224
+ backfillActive = false
225
+ // Only set loading to false when backfill completes or is cancelled
226
+ isLoading.value = false
227
+ emits('loading:stop', { fetched: masonry.value.length })
228
+ }
229
+ }
230
+
231
+ async function loadPage(page: number) {
232
+ if (isLoading.value) return
233
+ // Starting a new load should clear any previous cancel request
234
+ cancelRequested.value = false
235
+ if (!isLoading.value) {
236
+ isLoading.value = true
237
+ emits('loading:start')
238
+ }
239
+ // Reset hasReachedEnd and loadError when loading a new page
240
+ hasReachedEnd.value = false
241
+ loadError.value = null
242
+ try {
243
+ const baseline = masonry.value.length
244
+ if (cancelRequested.value) return
245
+ const response = await getContent(page)
246
+ if (cancelRequested.value) return
247
+ // Clear error on successful load
248
+ loadError.value = null
249
+ currentPage.value = page // Track the current page
250
+ paginationHistory.value.push(response.nextPage)
251
+ // Update hasReachedEnd if nextPage is null
252
+ if (response.nextPage == null) {
253
+ hasReachedEnd.value = true
254
+ }
255
+ await maybeBackfillToTarget(baseline)
256
+ return response
257
+ } catch (error) {
258
+ // Set load error - error is handled and exposed to UI via loadError
259
+ loadError.value = normalizeError(error)
260
+ throw error
261
+ } finally {
262
+ isLoading.value = false
263
+ }
264
+ }
265
+
266
+ async function loadNext() {
267
+ if (isLoading.value) return
268
+ // Don't load if we've already reached the end
269
+ if (hasReachedEnd.value) return
270
+ // Starting a new load should clear any previous cancel request
271
+ cancelRequested.value = false
272
+ if (!isLoading.value) {
273
+ isLoading.value = true
274
+ emits('loading:start')
275
+ }
276
+ // Clear error when attempting to load
277
+ loadError.value = null
278
+ try {
279
+ const baseline = masonry.value.length
280
+ if (cancelRequested.value) return
281
+
282
+ // Refresh mode: check if current page needs refreshing before loading next
283
+ if (mode === 'refresh' && currentPage.value != null) {
284
+ const currentPageItemCount = countItemsForPage(currentPage.value)
285
+
286
+ // If current page has fewer items than pageSize, refresh it first
287
+ if (currentPageItemCount < pageSize) {
288
+ const response = await fetchWithRetry(() => getPage(currentPage.value, context?.value))
289
+ if (cancelRequested.value) return
290
+
291
+ // Get only new items that don't already exist
292
+ // We need to check against the current masonry state at this moment
293
+ const currentMasonrySnapshot = [...masonry.value]
294
+ const newItems = response.items.filter((item: any) => {
295
+ if (!item || item.id == null || item.page == null) return false
296
+ return !currentMasonrySnapshot.some((existing: any) => {
297
+ return existing && existing.id === item.id && existing.page === item.page
298
+ })
299
+ })
300
+
301
+ // Append only new items to masonry (same pattern as getContent)
302
+ if (newItems.length > 0) {
303
+ const updatedItems = [...masonry.value, ...newItems]
304
+ refreshLayout(updatedItems)
305
+ // Wait a tick for masonry to update
306
+ await new Promise(resolve => setTimeout(resolve, 0))
307
+ }
308
+
309
+ // Clear error on successful load
310
+ loadError.value = null
311
+
312
+ // If no new items were found, automatically proceed to next page
313
+ // This means the current page has no more items available
314
+ if (newItems.length === 0) {
315
+ const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
316
+ if (nextPageToLoad == null) {
317
+ hasReachedEnd.value = true
318
+ return
319
+ }
320
+ const nextResponse = await getContent(nextPageToLoad)
321
+ if (cancelRequested.value) return
322
+ loadError.value = null
323
+ currentPage.value = nextPageToLoad
324
+ paginationHistory.value.push(nextResponse.nextPage)
325
+ if (nextResponse.nextPage == null) {
326
+ hasReachedEnd.value = true
327
+ }
328
+ await maybeBackfillToTarget(baseline)
329
+ return nextResponse
330
+ }
331
+
332
+ // If we now have enough items for current page, proceed to next page
333
+ // Re-check count after items have been added
334
+ const updatedCount = countItemsForPage(currentPage.value)
335
+ if (updatedCount >= pageSize) {
336
+ // Current page is now full, proceed with normal next page loading
337
+ const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
338
+ if (nextPageToLoad == null) {
339
+ hasReachedEnd.value = true
340
+ return
341
+ }
342
+ const nextResponse = await getContent(nextPageToLoad)
343
+ if (cancelRequested.value) return
344
+ loadError.value = null
345
+ currentPage.value = nextPageToLoad
346
+ paginationHistory.value.push(nextResponse.nextPage)
347
+ if (nextResponse.nextPage == null) {
348
+ hasReachedEnd.value = true
349
+ }
350
+ await maybeBackfillToTarget(baseline)
351
+ return nextResponse
352
+ } else {
353
+ // Still not enough items, but we refreshed - return the refresh response
354
+ return response
355
+ }
356
+ }
357
+ }
358
+
359
+ // Normal flow: load next page
360
+ const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
361
+ // Don't load if nextPageToLoad is null
362
+ if (nextPageToLoad == null) {
363
+ hasReachedEnd.value = true
364
+ return
365
+ }
366
+ const response = await getContent(nextPageToLoad)
367
+ if (cancelRequested.value) return
368
+ // Clear error on successful load
369
+ loadError.value = null
370
+ currentPage.value = nextPageToLoad // Track the current page
371
+ paginationHistory.value.push(response.nextPage)
372
+ // Update hasReachedEnd if nextPage is null
373
+ if (response.nextPage == null) {
374
+ hasReachedEnd.value = true
375
+ }
376
+ await maybeBackfillToTarget(baseline)
377
+ return response
378
+ } catch (error) {
379
+ // Set load error - error is handled and exposed to UI via loadError
380
+ loadError.value = normalizeError(error)
381
+ throw error
382
+ } finally {
383
+ isLoading.value = false
384
+ emits('loading:stop', { fetched: masonry.value.length })
385
+ }
386
+ }
387
+
388
+ async function refreshCurrentPage() {
389
+ if (isLoading.value) return
390
+ cancelRequested.value = false
391
+ isLoading.value = true
392
+ emits('loading:start')
393
+
394
+ try {
395
+ // Use the tracked current page
396
+ const pageToRefresh = currentPage.value
397
+
398
+ if (pageToRefresh == null) {
399
+ console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
400
+ return
401
+ }
402
+
403
+ // Clear existing items
404
+ masonry.value = []
405
+ // Reset end flag when refreshing
406
+ hasReachedEnd.value = false
407
+ // Reset error flag when refreshing
408
+ loadError.value = null
409
+
410
+ // Reset pagination history to just the current page
411
+ paginationHistory.value = [pageToRefresh]
412
+
413
+ // Reload the current page
414
+ const response = await getContent(pageToRefresh)
415
+ if (cancelRequested.value) return
416
+
417
+ // Clear error on successful load
418
+ loadError.value = null
419
+ // Update pagination state
420
+ currentPage.value = pageToRefresh
421
+ paginationHistory.value.push(response.nextPage)
422
+ // Update hasReachedEnd if nextPage is null
423
+ if (response.nextPage == null) {
424
+ hasReachedEnd.value = true
425
+ }
426
+
427
+ // Optionally backfill if needed
428
+ const baseline = masonry.value.length
429
+ await maybeBackfillToTarget(baseline)
430
+
431
+ return response
432
+ } catch (error) {
433
+ // Set load error - error is handled and exposed to UI via loadError
434
+ loadError.value = normalizeError(error)
435
+ throw error
436
+ } finally {
437
+ isLoading.value = false
438
+ emits('loading:stop', { fetched: masonry.value.length })
439
+ }
440
+ }
441
+
442
+ function cancelLoad() {
443
+ const wasBackfilling = backfillActive
444
+ cancelRequested.value = true
445
+ isLoading.value = false
446
+ // Set backfillActive to false to immediately stop backfilling
447
+ // The backfill loop checks this flag and will exit on the next iteration
448
+ backfillActive = false
449
+ // If backfill was active, emit stop event immediately
450
+ if (wasBackfilling) {
451
+ emits('backfill:stop', { fetched: masonry.value.length, calls: 0, cancelled: true })
452
+ }
453
+ emits('loading:stop', { fetched: masonry.value.length })
454
+ }
455
+
456
+ return {
457
+ loadPage,
458
+ loadNext,
459
+ refreshCurrentPage,
460
+ cancelLoad,
461
+ maybeBackfillToTarget,
462
+ getContent
463
+ }
464
+ }
465
+