From 5a2fef885820741f6c42d27349bea49d473b3d67 Mon Sep 17 00:00:00 2001 From: gatewayJ <835269233@qq.com> Date: Wed, 7 Oct 2026 10:27:53 +0800 Subject: [PATCH 1/2] fix(browser): continue loading after empty object pages --- components/object/list.tsx | 14 +++++++++----- docs/ui-review/register.md | 2 ++ lib/object-list-state.ts | 4 +++- tests/lib/object-list-source.test.js | 2 +- tests/lib/object-list-state.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/components/object/list.tsx b/components/object/list.tsx index 2c68fb3d..d2ecca41 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -433,14 +433,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) => { @@ -852,7 +856,7 @@ export function ObjectList({ React.useEffect(() => { const node = loadMoreRef.current - if (!node || !nextToken || typeof IntersectionObserver === "undefined") return + if (!node || !nextToken || loading || loadMoreError || typeof IntersectionObserver === "undefined") return const observer = new IntersectionObserver( (entries) => { @@ -868,7 +872,7 @@ export function ObjectList({ return () => { observer.disconnect() } - }, [loadNextBatch, nextToken]) + }, [loadNextBatch, nextToken, loading, loadMoreError]) return (
@@ -994,7 +998,7 @@ export function ObjectList({
) : null} - {data.length > 0 ? ( + {data.length > 0 || nextToken ? (
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/tests/lib/object-list-source.test.js b/tests/lib/object-list-source.test.js index fb790a0c..89560f05 100644 --- a/tests/lib/object-list-source.test.js +++ b/tests/lib/object-list-source.test.js @@ -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", + ) +}) From a2cb0b3278343f5364a8669e9a91399b25e28801 Mon Sep 17 00:00:00 2001 From: gatewayJ <835269233@qq.com> Date: Wed, 7 Oct 2026 14:32:31 +0800 Subject: [PATCH 2/2] test: cover object pagination observer transitions --- components/object/list.tsx | 21 +-- hooks/use-object-list-pagination.ts | 37 +++++ package.json | 3 +- pnpm-lock.yaml | 66 ++++++++ .../hooks/use-object-list-pagination.test.ts | 153 ++++++++++++++++++ tests/lib/object-list-source.test.js | 2 +- 6 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 hooks/use-object-list-pagination.ts create mode 100644 tests/hooks/use-object-list-pagination.test.ts diff --git a/components/object/list.tsx b/components/object/list.tsx index d2ecca41..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" @@ -854,25 +855,7 @@ export function ObjectList({ void fetchObjects({ token: nextToken, append: true }) }, [fetchObjects, nextToken]) - React.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() - } - }, [loadNextBatch, nextToken, loading, loadMoreError]) + useObjectListPagination({ loadMoreRef, nextToken, loading, loadMoreError, loadNextBatch }) return (
diff --git a/hooks/use-object-list-pagination.ts b/hooks/use-object-list-pagination.ts new file mode 100644 index 00000000..7be2448a --- /dev/null +++ b/hooks/use-object-list-pagination.ts @@ -0,0 +1,37 @@ +import { useEffect, type RefObject } from "react" + +interface ObjectListPaginationOptions { + loadMoreRef: RefObject + 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/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 89560f05..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)