Files
supabase/apps/studio/state/storage-explorer.test.ts
Francesco SansalvadoreandClaude Opus 5 c8a9a7a630 fix(studio): correct storage explorer listing pagination and column scroll (#50476)
| | PR | Base | Branch |
| --- | --- | --- | --- |
| 1 | **this PR** | `master` | pre-existing correctness fixes |
| 2 | #50413 | `fix/storage-explorer-listing-and-scroll` |
`?path`/`?preview` deep-linking + copy row actions |
| 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link
test |
| 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row
actions |

To read the whole change in one view:

```bash
git diff master...test/storage-deep-link-e2e -- apps/studio e2e
```

## What is the current behavior?

Four independent bugs in the storage explorer, all pre-existing on
`master`:

- `hasMoreItems` is derived from the *formatted* listing, but
`formatFolderItems` drops the `.emptyFolderPlaceholder` — so a full page
can format to `LIMIT - 1` and stop pagination a page early.
- A failed listing is indistinguishable from an empty folder, so a fetch
error reads as "this folder has nothing in it".
- `fetchFoldersByPath` commits its result against whichever bucket is
selected when the requests resolve. Switching buckets mid-flight files
the old bucket's items under the new bucket's name — and because
`columns[0].name` then matches, nothing downstream notices and
refetches.
- The horizontal auto-scroll never runs its guard (`if
(fileExplorerRef)` is always truthy), scrolls relatively so repeated
runs drift, and depends on the `columns` array identity — so a
background refetch yanks the view back to the right. It also scrolls in
list view, where there is nothing to scroll.

## What is the new behavior?

Each of the above is fixed at its source. Pagination and the
exhaustiveness check now compare the raw page length; listings carry an
`isComplete` flag; `fetchFoldersByPath` captures the bucket id at entry
and discards a stale result; the scroll is absolute, guarded, keyed on
`columns.length`, and skipped in list view.

Two new test files cover the parts that were silently wrong before:
`state/storage-explorer.test.ts` (MSW, the bucket race) and
`FileExplorer.test.tsx` (scroll geometry, with the container's layout
defined by hand since jsdom reports everything as zero-sized). Both were
checked by reverting the fix and confirming they fail.

## Additional context

`fetchFoldersByPath` also starts returning `{ missingPaths }` here.
Nothing reads it yet — the first consumer is in PR 2 — but it shares a
hunk with the `isComplete` work, so separating it would mean two PRs
editing the same lines. It is backward-compatible: all three existing
call sites ignore the return value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Bug Fixes

- Improved Storage Explorer column-view scrolling so the newest column
remains visible, including when the preview pane opens.
- Prevented folder results from a previously selected bucket from
appearing after switching buckets during loading.
- Improved handling of incomplete or partial folder listings to avoid
incorrectly treating failed results as empty folders.
- Preserved the correct scroll position when using list view.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:05:23 +02:00

110 lines
3.2 KiB
TypeScript

import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import { createStorageExplorerState } from './storage-explorer'
import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation'
import type { Bucket } from '@/data/storage/buckets-query'
import { addAPIMock } from '@/tests/lib/msw'
function makeBucket(id: string): Bucket {
return {
id,
name: id,
owner: 'owner',
public: false,
type: 'STANDARD',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
} as Bucket
}
const BUCKET_A_LISTING: StorageObject[] = [
{
id: 'file-in-bucket-a',
name: 'only-in-bucket-a.png',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_accessed_at: '2024-01-01T00:00:00Z',
metadata: { size: 1, mimetype: 'image/png' },
},
]
/** Objects with a null id are prefixes, i.e. folders. */
const FOLDER_LISTING: StorageObject[] = [
{
id: null,
name: 'shared',
created_at: null,
updated_at: null,
last_accessed_at: null,
metadata: null,
},
]
function createState(bucket: Bucket) {
return createStorageExplorerState({
projectRef: 'test-ref',
connectionString: '',
bucket,
resumableUploadUrl: '',
clientEndpoint: '',
})
}
describe('fetchFoldersByPath', () => {
beforeEach(() => {
localStorage.clear()
})
it('discards a restore that resolves after the bucket changed', async () => {
let releaseListing: (() => void) | undefined
const listingReleased = new Promise<void>((resolve) => {
releaseListing = resolve
})
addAPIMock({
method: 'post',
path: '/platform/storage/:ref/buckets/:id/objects/list',
response: async () => {
await listingReleased
// Belongs to bucket-a, the bucket the request was issued for
return HttpResponse.json<StorageObject[]>(BUCKET_A_LISTING)
},
})
const state = createState(makeBucket('bucket-a'))
const restore = state.fetchFoldersByPath({ paths: ['shared'], showLoading: true })
// The user switches buckets while the listing is still in flight. Same `?path`, so
// nothing about the requested location changes — only the bucket underneath it.
state.selectedBucket = makeBucket('bucket-b')
releaseListing?.()
const { missingPaths } = await restore
// bucket-a's contents must not be committed, and must not be relabelled as bucket-b
const allItems = state.columns.flatMap((column) => column.items)
expect(allItems).toHaveLength(0)
expect(state.columns[0]?.name).toBe('bucket-a')
expect(missingPaths).toEqual([])
})
it('commits the restore when the bucket is unchanged', async () => {
addAPIMock({
method: 'post',
path: '/platform/storage/:ref/buckets/:id/objects/list',
response: FOLDER_LISTING,
})
const state = createState(makeBucket('bucket-a'))
const { missingPaths } = await state.fetchFoldersByPath({
paths: ['shared'],
showLoading: true,
})
expect(missingPaths).toEqual([])
expect(state.columns[0]?.items.map((item) => item.name)).toEqual(['shared'])
expect(state.openedFolders.map((folder) => folder.name)).toEqual(['shared'])
})
})