diff --git a/components/object/list.tsx b/components/object/list.tsx
index 2c68fb3d..6c5e144e 100644
--- a/components/object/list.tsx
+++ b/components/object/list.tsx
@@ -53,6 +53,7 @@ import { DANGER_BUTTON_CLASS } from "@/components/danger-button"
import { Spinner } from "@/components/ui/spinner"
import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
+import { useObjectListPagination } from "@/hooks/use-object-list-pagination"
import { useBucket } from "@/hooks/use-bucket"
import { useLocalStorage } from "@/hooks/use-local-storage"
import { usePermissions } from "@/hooks/use-permissions"
@@ -433,14 +434,18 @@ export function ObjectList({
const filteredEmptyState = displayState === "filtered-partial" || displayState === "filtered-empty"
const emptyTitle = filteredEmptyState
? t(displayState === "filtered-partial" ? "No matches in loaded objects" : "No matching objects")
- : t("No Objects")
+ : displayState === "partial"
+ ? t("Load next objects")
+ : t("No Objects")
const emptyDescription = filteredEmptyState
? t(
displayState === "filtered-partial"
? "More objects have not been searched yet."
: "No loaded objects match this filter.",
)
- : t("Upload files or create folders to populate this bucket.")
+ : displayState === "partial"
+ ? t("More objects have not been searched yet.")
+ : t("Upload files or create folders to populate this bucket.")
const downloadFile = React.useCallback(
async (key: string) => {
@@ -850,25 +855,7 @@ export function ObjectList({
void fetchObjects({ token: nextToken, append: true })
}, [fetchObjects, nextToken])
- React.useEffect(() => {
- const node = loadMoreRef.current
- if (!node || !nextToken || typeof IntersectionObserver === "undefined") return
-
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries.some((entry) => entry.isIntersecting)) {
- loadNextBatch()
- }
- },
- { rootMargin: "320px 0px" },
- )
-
- observer.observe(node)
-
- return () => {
- observer.disconnect()
- }
- }, [loadNextBatch, nextToken])
+ useObjectListPagination({ loadMoreRef, nextToken, loading, loadMoreError, loadNextBatch })
return (
+ nextToken: string | undefined
+ loading: boolean
+ loadMoreError: boolean
+ loadNextBatch: () => void
+}
+
+export function useObjectListPagination({
+ loadMoreRef,
+ nextToken,
+ loading,
+ loadMoreError,
+ loadNextBatch,
+}: ObjectListPaginationOptions) {
+ useEffect(() => {
+ const node = loadMoreRef.current
+ if (!node || !nextToken || loading || loadMoreError || typeof IntersectionObserver === "undefined") return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ loadNextBatch()
+ }
+ },
+ { rootMargin: "320px 0px" },
+ )
+
+ observer.observe(node)
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [loadMoreRef, loadNextBatch, nextToken, loading, loadMoreError])
+}
diff --git a/lib/object-list-state.ts b/lib/object-list-state.ts
index 8b92845c..f100da7e 100644
--- a/lib/object-list-state.ts
+++ b/lib/object-list-state.ts
@@ -15,6 +15,7 @@ interface ObjectListResponseGuardParams {
export type ObjectListDisplayState =
| "loading"
| "empty"
+ | "partial"
| "filtered-loading"
| "filtered-partial"
| "filtered-empty"
@@ -69,7 +70,8 @@ export function resolveObjectListDisplayState({
const isFiltering = searchTerm.trim().length > 0
if (!isFiltering) {
- return loading && loadedCount === 0 ? "loading" : "empty"
+ if (loading && loadedCount === 0) return "loading"
+ return hasMore ? "partial" : "empty"
}
if (loading) return "filtered-loading"
diff --git a/package.json b/package.json
index abec06ac..9c4e3045 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
- "test:run": "node --experimental-strip-types --import ./scripts/register-typescript-loader.mjs --test tests/lib/*.test.{js,ts}",
+ "test:run": "node --experimental-strip-types --import ./scripts/register-typescript-loader.mjs --test tests/lib/*.test.{js,ts} tests/hooks/*.test.ts",
"type-check": "node scripts/apply-theme-overrides.js && tsc --noEmit",
"prettier": "prettier",
"format": "prettier --write .",
@@ -61,6 +61,7 @@
"eslint": "^9.39.4",
"eslint-config-next": "16.3.0",
"eslint-config-prettier": "^10.1.8",
+ "happy-dom": "20.14.5",
"prettier": "^3.8.4",
"shadcn": "^4.16.2",
"tailwindcss": "^4.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c33ef71..8bc02ca1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -236,6 +236,9 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@9.39.4(jiti@2.7.0))
+ happy-dom:
+ specifier: 20.14.5
+ version: 20.14.5
prettier:
specifier: ^3.8.4
version: 3.8.4
@@ -1286,6 +1289,12 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
'@typescript-eslint/eslint-plugin@8.66.0':
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1602,6 +1611,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -1910,6 +1923,10 @@ packages:
resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==}
engines: {node: '>=10.13.0'}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -2292,6 +2309,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ happy-dom@20.14.5:
+ resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
+ engines: {node: '>=20.0.0'}
+
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3599,6 +3620,10 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -3632,6 +3657,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ ws@8.22.0:
+ resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
@@ -4751,6 +4788,12 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 22.19.21
+
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -5081,6 +5124,10 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 22.19.21
+
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
@@ -5342,6 +5389,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@7.0.1: {}
+
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -5920,6 +5969,19 @@ snapshots:
graceful-fs@4.2.11: {}
+ happy-dom@20.14.5:
+ dependencies:
+ '@types/node': 22.19.21
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.2
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.22.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -7292,6 +7354,8 @@ snapshots:
void-elements@3.1.0: {}
+ whatwg-mimetype@3.0.0: {}
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -7345,6 +7409,8 @@ snapshots:
wrappy@1.0.2: {}
+ ws@8.22.0: {}
+
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.0
diff --git a/tests/hooks/use-object-list-pagination.test.ts b/tests/hooks/use-object-list-pagination.test.ts
new file mode 100644
index 00000000..00761abc
--- /dev/null
+++ b/tests/hooks/use-object-list-pagination.test.ts
@@ -0,0 +1,153 @@
+import test, { type TestContext } from "node:test"
+import assert from "node:assert/strict"
+import { act, createElement } from "react"
+import { createRoot } from "react-dom/client"
+import { Window } from "happy-dom"
+import { useObjectListPagination } from "../../hooks/use-object-list-pagination"
+
+type PaginationProps = Omit
[0], "loadMoreRef">
+
+function Pagination(props: Parameters[0]) {
+ useObjectListPagination(props)
+ return null
+}
+
+async function mountPagination(t: TestContext, props: PaginationProps) {
+ const browser = new Window()
+ const observers: MockIntersectionObserver[] = []
+
+ class MockIntersectionObserver {
+ targets = new Set()
+ callback: IntersectionObserverCallback
+
+ constructor(callback: IntersectionObserverCallback) {
+ this.callback = callback
+ observers.push(this)
+ }
+
+ observe(target: Element) {
+ this.targets.add(target)
+ }
+
+ disconnect() {
+ this.targets.clear()
+ }
+
+ intersect() {
+ if (!this.targets.size) return
+ const entries = Array.from(this.targets, (target) => ({ isIntersecting: true, target }))
+ this.callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver)
+ }
+ }
+
+ const globals = {
+ window: browser,
+ document: browser.document,
+ IntersectionObserver: MockIntersectionObserver,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ }
+ const previousDescriptors = Object.keys(globals).map(
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
+ )
+ for (const [key, value] of Object.entries(globals)) {
+ Object.defineProperty(globalThis, key, { configurable: true, writable: true, value })
+ }
+
+ const container = browser.document.createElement("div")
+ browser.document.body.appendChild(container)
+ const root = createRoot(container as unknown as Element)
+ const loadMoreRef = { current: container as unknown as HTMLDivElement }
+ t.after(async () => {
+ await act(async () => root.unmount())
+ await browser.happyDOM.close()
+ for (const [key, descriptor] of previousDescriptors) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor)
+ else Reflect.deleteProperty(globalThis, key)
+ }
+ })
+
+ const render = async (changes: Partial = {}) => {
+ props = { ...props, ...changes }
+ await act(async () => root.render(createElement(Pagination, { ...props, loadMoreRef })))
+ }
+ const intersect = async () => {
+ await act(async () => observers.forEach((observer) => observer.intersect()))
+ }
+ await render()
+ return { render, intersect, observers }
+}
+
+test("empty append pages resume observation only after loading clears", async (t) => {
+ const response = Promise.withResolvers<{ Contents: never[]; NextContinuationToken: string }>()
+ let token = "page-2"
+ const append = t.mock.fn<(token: string) => typeof response.promise>(() => response.promise)
+ const loadNextBatch = () => void append(token)
+ const { render, intersect, observers } = await mountPagination(t, {
+ nextToken: token,
+ loading: true,
+ loadMoreError: false,
+ loadNextBatch,
+ })
+
+ assert.equal(observers.length, 0)
+ await render({ loading: false })
+ await intersect()
+ assert.equal(append.mock.callCount(), 1)
+
+ await render({ loading: true })
+ await intersect()
+ assert.equal(append.mock.callCount(), 1)
+ assert.equal(observers[0].targets.size, 0)
+
+ response.resolve({ Contents: [], NextContinuationToken: "page-3" })
+ token = (await response.promise).NextContinuationToken
+ // The response token is committed before fetchObjects clears loading.
+ await render({ nextToken: token })
+ await intersect()
+ assert.equal(append.mock.callCount(), 1)
+
+ await render({ loading: false })
+ await intersect()
+ assert.deepEqual(
+ append.mock.calls.map((call) => call.arguments[0]),
+ ["page-2", "page-3"],
+ )
+})
+
+test("an append failure pauses automatic requests until a manual retry", async (t) => {
+ const response = Promise.withResolvers()
+ const append = t.mock.fn(() => response.promise)
+ let request: Promise | undefined
+ const loadNextBatch = () => {
+ request = append().then(
+ () => true,
+ () => false,
+ )
+ }
+ const { render, intersect } = await mountPagination(t, {
+ nextToken: "page-2",
+ loading: false,
+ loadMoreError: false,
+ loadNextBatch,
+ })
+
+ await intersect()
+ await render({ loading: true })
+ response.reject(new Error("Append request failed"))
+ assert.equal(await request, false)
+ await render({ loadMoreError: true })
+ await render({ loading: false })
+ await intersect()
+ await render()
+ await intersect()
+ assert.equal(append.mock.callCount(), 1)
+
+ append.mock.mockImplementation(() => Promise.resolve())
+ loadNextBatch()
+ await render({ loading: true, loadMoreError: false })
+ await intersect()
+ assert.equal(await request, true)
+ await render({ loading: false, nextToken: undefined })
+ await intersect()
+ assert.equal(append.mock.callCount(), 2)
+})
diff --git a/tests/lib/object-list-source.test.js b/tests/lib/object-list-source.test.js
index fb790a0c..c0c2b5e2 100644
--- a/tests/lib/object-list-source.test.js
+++ b/tests/lib/object-list-source.test.js
@@ -25,7 +25,7 @@ test("object list distinguishes access errors from a confirmed empty bucket", ()
test("object list lazy loads additional object batches instead of showing a paginator", () => {
const source = fs.readFileSync("components/object/list.tsx", "utf8")
- assert.equal(source.includes("IntersectionObserver"), true)
+ assert.match(source, /useObjectListPagination\(\{ loadMoreRef, nextToken, loading, loadMoreError, loadNextBatch \}\)/)
assert.equal(source.includes("setData((currentRows) => [...currentRows, ...rows])"), true)
assert.equal(source.includes('t("Rows per page")'), false)
assert.equal(source.includes('t("Previous Page")'), false)
@@ -38,7 +38,7 @@ test("last modified sorting discloses partial results and keeps continuation vis
assert.match(source, /\{nextToken \? \(\s+]*>\{t\("Loaded objects only"\)\}/)
assert.match(
source,
- /\{data\.length > 0 \? \(\s+ 0 \|\| nextToken \? \(\s+
{
+ assert.equal(
+ resolveObjectListDisplayState({
+ searchTerm: "",
+ filteredCount: 0,
+ loadedCount: 0,
+ hasMore: true,
+ loading: false,
+ }),
+ "partial",
+ )
+})
+
+test("continuing an empty protected-list page displays loading", () => {
+ assert.equal(
+ resolveObjectListDisplayState({
+ searchTerm: "",
+ filteredCount: 0,
+ loadedCount: 0,
+ hasMore: true,
+ loading: true,
+ }),
+ "loading",
+ )
+})