Skip to content

Commit 62267f5

Browse files
committed
fix: reset object pagination when changing folders
1 parent 8ab0668 commit 62267f5

3 files changed

Lines changed: 217 additions & 8 deletions

File tree

components/object/list.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import { exportFile } from "@/lib/export-file"
3939
import { getContentType } from "@/lib/mime-types"
4040
import { formatBytes } from "@/lib/functions"
4141
import { buildBucketPath } from "@/lib/bucket-path"
42+
import {
43+
createObjectListScope,
44+
shouldApplyObjectListResponse,
45+
shouldResetObjectListPagination,
46+
} from "@/lib/object-list-state"
4247
import {
4348
resolveBucketVersioningState,
4449
shouldForceDeleteObjects,
@@ -104,14 +109,34 @@ export function ObjectList({
104109
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
105110

106111
const prefix = decodeURIComponent(path)
112+
const listScope = React.useMemo(
113+
() =>
114+
createObjectListScope({
115+
bucket,
116+
prefix,
117+
pageSize,
118+
showDeleted,
119+
}),
120+
[bucket, prefix, pageSize, showDeleted],
121+
)
107122
const canBulkDelete = canCapability("objects.bulkDelete", { bucket, prefix })
108123
const canBulkDownload = canCapability("objects.download", { bucket, prefix })
109124

110125
const bucketPath = React.useCallback((p?: string | string[]) => buildBucketPath(bucket, p), [bucket])
126+
const requestIdRef = React.useRef(0)
127+
const activeScopeRef = React.useRef(listScope)
128+
const previousScopeRef = React.useRef(listScope)
129+
130+
React.useEffect(() => {
131+
activeScopeRef.current = listScope
132+
}, [listScope])
111133

112134
const fetchObjects = React.useCallback(
113-
async (tokenOverride?: string) => {
114-
const token = tokenOverride !== undefined ? tokenOverride : continuationToken
135+
async (options?: { token?: string; resetPagination?: boolean }) => {
136+
const token = options?.resetPagination ? undefined : (options?.token ?? continuationToken)
137+
const requestId = requestIdRef.current + 1
138+
requestIdRef.current = requestId
139+
const requestScope = activeScopeRef.current
115140
setLoading(true)
116141
try {
117142
const response = await listObject(bucket, prefix || undefined, pageSize, token, {
@@ -124,6 +149,17 @@ export function ObjectList({
124149
NextContinuationToken?: string
125150
}
126151

152+
if (
153+
!shouldApplyObjectListResponse({
154+
requestId,
155+
activeRequestId: requestIdRef.current,
156+
requestScope,
157+
activeScope: activeScopeRef.current,
158+
})
159+
) {
160+
return
161+
}
162+
127163
setNextToken(r.NextContinuationToken)
128164

129165
const prefixItems: ObjectRow[] = (r.CommonPrefixes ?? []).map((item) => ({
@@ -142,7 +178,18 @@ export function ObjectList({
142178

143179
setData([...prefixItems, ...objectItems])
144180
} finally {
145-
setTimeout(() => setLoading(false), 200)
181+
window.setTimeout(() => {
182+
if (
183+
shouldApplyObjectListResponse({
184+
requestId,
185+
activeRequestId: requestIdRef.current,
186+
requestScope,
187+
activeScope: activeScopeRef.current,
188+
})
189+
) {
190+
setLoading(false)
191+
}
192+
}, 200)
146193
}
147194
},
148195
[bucket, prefix, pageSize, continuationToken, showDeleted, listObject],
@@ -151,17 +198,19 @@ export function ObjectList({
151198
const prevRefreshTriggerRef = React.useRef(refreshTrigger)
152199

153200
React.useEffect(() => {
201+
const shouldResetPagination = shouldResetObjectListPagination(previousScopeRef.current, listScope)
202+
previousScopeRef.current = listScope
154203
const isRefresh = prevRefreshTriggerRef.current !== refreshTrigger
155204
prevRefreshTriggerRef.current = refreshTrigger
156205

157-
if (isRefresh) {
206+
if (isRefresh || shouldResetPagination) {
158207
setContinuationToken(undefined)
159208
setTokenHistory([])
160-
void fetchObjects(undefined)
209+
void fetchObjects({ resetPagination: true })
161210
} else {
162211
void fetchObjects()
163212
}
164-
}, [bucket, prefix, pageSize, continuationToken, showDeleted, refreshTrigger, fetchObjects])
213+
}, [listScope, bucket, prefix, pageSize, continuationToken, showDeleted, refreshTrigger, fetchObjects])
165214

166215
const prevDeleteTaskIdsRef = React.useRef<Set<string>>(new Set())
167216

@@ -175,7 +224,7 @@ export function ObjectList({
175224
const anyActive = currentDeleteTasks.some((t) => ["pending", "running"].includes(t.status))
176225
if (!anyActive) {
177226
// No more active delete tasks, refresh the list
178-
void fetchObjects()
227+
void fetchObjects({ resetPagination: true })
179228
}
180229
}
181230

@@ -576,7 +625,7 @@ export function ObjectList({
576625
) : null}
577626
</>
578627
) : null}
579-
<Button variant="outline" onClick={() => (onRefresh ?? fetchObjects)()}>
628+
<Button variant="outline" onClick={() => (onRefresh ? onRefresh() : void fetchObjects({ resetPagination: true }))}>
580629
<RiRefreshLine className="size-4" />
581630
<span>{t("Refresh")}</span>
582631
</Button>

lib/object-list-state.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export interface ObjectListScope {
2+
bucket: string
3+
prefix: string
4+
pageSize: number
5+
showDeleted: boolean
6+
}
7+
8+
interface ObjectListResponseGuardParams {
9+
requestId: number
10+
activeRequestId: number
11+
requestScope: ObjectListScope
12+
activeScope: ObjectListScope
13+
}
14+
15+
export function createObjectListScope(scope: ObjectListScope): ObjectListScope {
16+
return scope
17+
}
18+
19+
export function isSameObjectListScope(left: ObjectListScope, right: ObjectListScope): boolean {
20+
return (
21+
left.bucket === right.bucket &&
22+
left.prefix === right.prefix &&
23+
left.pageSize === right.pageSize &&
24+
left.showDeleted === right.showDeleted
25+
)
26+
}
27+
28+
export function shouldResetObjectListPagination(previousScope: ObjectListScope, nextScope: ObjectListScope): boolean {
29+
return !isSameObjectListScope(previousScope, nextScope)
30+
}
31+
32+
export function shouldApplyObjectListResponse({
33+
requestId,
34+
activeRequestId,
35+
requestScope,
36+
activeScope,
37+
}: ObjectListResponseGuardParams): boolean {
38+
return requestId === activeRequestId && isSameObjectListScope(requestScope, activeScope)
39+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
import {
4+
createObjectListScope,
5+
shouldApplyObjectListResponse,
6+
shouldResetObjectListPagination,
7+
} from "../../lib/object-list-state.js"
8+
9+
test("shouldResetObjectListPagination returns false for the same listing scope", () => {
10+
const previousScope = createObjectListScope({
11+
bucket: "bucket-a",
12+
prefix: "folder-a/",
13+
pageSize: 25,
14+
showDeleted: false,
15+
})
16+
const nextScope = createObjectListScope({
17+
bucket: "bucket-a",
18+
prefix: "folder-a/",
19+
pageSize: 25,
20+
showDeleted: false,
21+
})
22+
23+
assert.equal(shouldResetObjectListPagination(previousScope, nextScope), false)
24+
})
25+
26+
test("shouldResetObjectListPagination returns true when prefix changes", () => {
27+
const previousScope = createObjectListScope({
28+
bucket: "bucket-a",
29+
prefix: "folder-a/",
30+
pageSize: 25,
31+
showDeleted: false,
32+
})
33+
const nextScope = createObjectListScope({
34+
bucket: "bucket-a",
35+
prefix: "",
36+
pageSize: 25,
37+
showDeleted: false,
38+
})
39+
40+
assert.equal(shouldResetObjectListPagination(previousScope, nextScope), true)
41+
})
42+
43+
test("shouldResetObjectListPagination returns true when the bucket changes", () => {
44+
const previousScope = createObjectListScope({
45+
bucket: "bucket-a",
46+
prefix: "",
47+
pageSize: 25,
48+
showDeleted: false,
49+
})
50+
const nextScope = createObjectListScope({
51+
bucket: "bucket-b",
52+
prefix: "",
53+
pageSize: 25,
54+
showDeleted: false,
55+
})
56+
57+
assert.equal(shouldResetObjectListPagination(previousScope, nextScope), true)
58+
})
59+
60+
test("shouldApplyObjectListResponse rejects stale requests", () => {
61+
assert.equal(
62+
shouldApplyObjectListResponse({
63+
requestId: 2,
64+
activeRequestId: 3,
65+
requestScope: createObjectListScope({
66+
bucket: "bucket-a",
67+
prefix: "",
68+
pageSize: 25,
69+
showDeleted: false,
70+
}),
71+
activeScope: createObjectListScope({
72+
bucket: "bucket-a",
73+
prefix: "",
74+
pageSize: 25,
75+
showDeleted: false,
76+
}),
77+
}),
78+
false,
79+
)
80+
})
81+
82+
test("shouldApplyObjectListResponse rejects responses from an old scope", () => {
83+
assert.equal(
84+
shouldApplyObjectListResponse({
85+
requestId: 3,
86+
activeRequestId: 3,
87+
requestScope: createObjectListScope({
88+
bucket: "bucket-a",
89+
prefix: "folder-a/",
90+
pageSize: 25,
91+
showDeleted: false,
92+
}),
93+
activeScope: createObjectListScope({
94+
bucket: "bucket-a",
95+
prefix: "",
96+
pageSize: 25,
97+
showDeleted: false,
98+
}),
99+
}),
100+
false,
101+
)
102+
})
103+
104+
test("shouldApplyObjectListResponse accepts the latest response for the current scope", () => {
105+
const scope = createObjectListScope({
106+
bucket: "bucket-a",
107+
prefix: "",
108+
pageSize: 25,
109+
showDeleted: false,
110+
})
111+
112+
assert.equal(
113+
shouldApplyObjectListResponse({
114+
requestId: 4,
115+
activeRequestId: 4,
116+
requestScope: scope,
117+
activeScope: scope,
118+
}),
119+
true,
120+
)
121+
})

0 commit comments

Comments
 (0)