From 8fa07a8394bc54731c62e8f5b225cb5a9afb8bb6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 24 Aug 2026 11:44:43 +0530 Subject: [PATCH 1/2] fix(tables): clear row selection when the visible rows change Selection state in MultiSelectionTable stuck around across pagination and search, keyed only on row id. Selecting rows, then paginating to a different page and hitting "select all" would carry the old ids into the new batch delete, silently deleting rows the user never saw selected. Reported from Storage and Databases in production. Reset selection whenever the URL changes, and key the previously unkeyed row each-blocks (files, users, memberships, platforms, backups) so Svelte doesn't reuse row elements across renders. --- .../components/__tests__/appState.svelte.ts | 7 +++ .../__tests__/multiSelectTable.svelte.test.ts | 61 +++++++++++++++++++ .../__tests__/multiSelectTableHarness.svelte | 35 +++++++++++ src/lib/components/multiSelectTable.svelte | 16 ++++- .../auth/+page.svelte | 2 +- .../auth/user-[user]/memberships/+page.svelte | 2 +- .../database-[database]/backups/table.svelte | 2 +- .../overview/platforms/+page.svelte | 2 +- .../storage/bucket-[bucket]/+page.svelte | 2 +- vitest-setup-client.ts | 38 ++++++++++++ 10 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 src/lib/components/__tests__/appState.svelte.ts create mode 100644 src/lib/components/__tests__/multiSelectTable.svelte.test.ts create mode 100644 src/lib/components/__tests__/multiSelectTableHarness.svelte diff --git a/src/lib/components/__tests__/appState.svelte.ts b/src/lib/components/__tests__/appState.svelte.ts new file mode 100644 index 0000000000..de29f732a5 --- /dev/null +++ b/src/lib/components/__tests__/appState.svelte.ts @@ -0,0 +1,7 @@ +const BUCKET_URL = 'http://localhost/console/storage/bucket-a'; + +export const page = $state({ url: new URL(BUCKET_URL) }); + +export function navigateTo(href: string) { + page.url = new URL(href, BUCKET_URL); +} diff --git a/src/lib/components/__tests__/multiSelectTable.svelte.test.ts b/src/lib/components/__tests__/multiSelectTable.svelte.test.ts new file mode 100644 index 0000000000..1ac69df9ad --- /dev/null +++ b/src/lib/components/__tests__/multiSelectTable.svelte.test.ts @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { tick } from 'svelte'; +import Harness from './multiSelectTableHarness.svelte'; +import { navigateTo } from './appState.svelte'; + +vi.mock('$app/state', () => import('./appState.svelte')); + +vi.mock('$lib/stores/sdk', () => ({ + sdk: { + forConsole: { account: { getPrefs: () => Promise.resolve({}) } }, + forProject: () => ({}) + } +})); + +vi.mock('$lib/actions/analytics', () => ({ + trackEvent: vi.fn(), + trackError: vi.fn(), + Click: {}, + Submit: {} +})); + +const pages = { '1': ['a', 'b', 'c'], '2': ['d', 'e', 'f'] }; + +async function goToPageTwo() { + navigateTo('?page=2'); + await tick(); +} + +describe('MultiSelectionTable', () => { + beforeEach(() => navigateTo('?page=1')); + + it('drops the selection when another page of rows is loaded', async () => { + const user = userEvent.setup(); + render(Harness, { props: { pages, onDeleteIds: vi.fn() } }); + + const [, firstRow] = screen.getAllByRole('checkbox'); + await user.click(firstRow); + expect(screen.getByText('file selected')).toBeInTheDocument(); + + await goToPageTwo(); + + expect(screen.queryByText('file selected')).not.toBeInTheDocument(); + }); + + it('selects only the rows on screen after paginating', async () => { + const user = userEvent.setup(); + const onDeleteIds = vi.fn(); + render(Harness, { props: { pages, onDeleteIds } }); + + await goToPageTwo(); + + const [selectAll] = screen.getAllByRole('checkbox'); + await user.click(selectAll); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(onDeleteIds).toHaveBeenCalledWith(['d', 'e', 'f']); + }); +}); diff --git a/src/lib/components/__tests__/multiSelectTableHarness.svelte b/src/lib/components/__tests__/multiSelectTableHarness.svelte new file mode 100644 index 0000000000..d8da677b6d --- /dev/null +++ b/src/lib/components/__tests__/multiSelectTableHarness.svelte @@ -0,0 +1,35 @@ + + + { + onDeleteIds([...selectedRows]); + return { deleted: [] }; + }}> + {#snippet header(root)} + Id + {/snippet} + + {#snippet children(root)} + + {#each rows as id} + + {id} + + {/each} + {/snippet} + diff --git a/src/lib/components/multiSelectTable.svelte b/src/lib/components/multiSelectTable.svelte index ce0196e342..7b92b5e5de 100644 --- a/src/lib/components/multiSelectTable.svelte +++ b/src/lib/components/multiSelectTable.svelte @@ -12,6 +12,7 @@ -{#key computeKey} +{#key selectionKey} {@render header?.(root)} diff --git a/src/routes/(console)/project-[region]-[project]/auth/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/+page.svelte index e32114a8b6..e2ff9ba590 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/+page.svelte @@ -106,7 +106,7 @@ {/snippet} {#snippet children(root)} - {#each data.users.users as user} + {#each data.users.users as user (user.$id)} {platform.name} diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte index 7e18d3e1f9..ff4fa14df6 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte @@ -238,7 +238,7 @@ {/snippet} {#snippet children(root)} - {#each data.files.files as file} + {#each data.files.files as file (file.$id)} {#if file.chunksTotal / file.chunksUploaded !== 1} diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 88edcb4580..e2866666a9 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -1,6 +1,33 @@ import '@testing-library/jest-dom/vitest'; import { beforeAll, vi } from 'vitest'; +// jsdom hands back a bare object for web storage under this runtime, so the +// console's `localStorage` reads blow up before any component renders +function createStorage(): Storage { + const entries = new Map(); + + return { + get length() { + return entries.size; + }, + key: (index: number) => [...entries.keys()][index] ?? null, + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => void entries.set(key, String(value)), + removeItem: (key: string) => void entries.delete(key), + clear: () => entries.clear() + }; +} + +for (const name of ['localStorage', 'sessionStorage'] as const) { + if (typeof window[name]?.getItem === 'function') continue; + + Object.defineProperty(window, name, { + writable: true, + configurable: true, + value: createStorage() + }); +} + // required for svelte5 + jsdom as jsdom does not support matchMedia Object.defineProperty(window, 'matchMedia', { writable: true, @@ -15,6 +42,17 @@ Object.defineProperty(window, 'matchMedia', { })) }); +// jsdom ships no ResizeObserver, which pink-svelte's FloatingActionBar constructs on mount +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + configurable: true, + value: class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } +}); + beforeAll(() => { vi.mock('$app/environment', () => ({ dev: true, From 1d24c957a6cc36f250905706b44c73521f1949cf Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 24 Aug 2026 11:58:43 +0530 Subject: [PATCH 2/2] revert: drop the new component-test scaffolding This repo has no existing Svelte component test pattern, so covering the fix meant inventing one from scratch: jsdom localStorage/ ResizeObserver shims plus a harness component, none of which serves anything else here. Not warranted for a bug fix. The fix itself (selection reset on URL change + keyed row loops) stands on its own. --- .../components/__tests__/appState.svelte.ts | 7 --- .../__tests__/multiSelectTable.svelte.test.ts | 61 ------------------- .../__tests__/multiSelectTableHarness.svelte | 35 ----------- vitest-setup-client.ts | 38 ------------ 4 files changed, 141 deletions(-) delete mode 100644 src/lib/components/__tests__/appState.svelte.ts delete mode 100644 src/lib/components/__tests__/multiSelectTable.svelte.test.ts delete mode 100644 src/lib/components/__tests__/multiSelectTableHarness.svelte diff --git a/src/lib/components/__tests__/appState.svelte.ts b/src/lib/components/__tests__/appState.svelte.ts deleted file mode 100644 index de29f732a5..0000000000 --- a/src/lib/components/__tests__/appState.svelte.ts +++ /dev/null @@ -1,7 +0,0 @@ -const BUCKET_URL = 'http://localhost/console/storage/bucket-a'; - -export const page = $state({ url: new URL(BUCKET_URL) }); - -export function navigateTo(href: string) { - page.url = new URL(href, BUCKET_URL); -} diff --git a/src/lib/components/__tests__/multiSelectTable.svelte.test.ts b/src/lib/components/__tests__/multiSelectTable.svelte.test.ts deleted file mode 100644 index 1ac69df9ad..0000000000 --- a/src/lib/components/__tests__/multiSelectTable.svelte.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import userEvent from '@testing-library/user-event'; -import { tick } from 'svelte'; -import Harness from './multiSelectTableHarness.svelte'; -import { navigateTo } from './appState.svelte'; - -vi.mock('$app/state', () => import('./appState.svelte')); - -vi.mock('$lib/stores/sdk', () => ({ - sdk: { - forConsole: { account: { getPrefs: () => Promise.resolve({}) } }, - forProject: () => ({}) - } -})); - -vi.mock('$lib/actions/analytics', () => ({ - trackEvent: vi.fn(), - trackError: vi.fn(), - Click: {}, - Submit: {} -})); - -const pages = { '1': ['a', 'b', 'c'], '2': ['d', 'e', 'f'] }; - -async function goToPageTwo() { - navigateTo('?page=2'); - await tick(); -} - -describe('MultiSelectionTable', () => { - beforeEach(() => navigateTo('?page=1')); - - it('drops the selection when another page of rows is loaded', async () => { - const user = userEvent.setup(); - render(Harness, { props: { pages, onDeleteIds: vi.fn() } }); - - const [, firstRow] = screen.getAllByRole('checkbox'); - await user.click(firstRow); - expect(screen.getByText('file selected')).toBeInTheDocument(); - - await goToPageTwo(); - - expect(screen.queryByText('file selected')).not.toBeInTheDocument(); - }); - - it('selects only the rows on screen after paginating', async () => { - const user = userEvent.setup(); - const onDeleteIds = vi.fn(); - render(Harness, { props: { pages, onDeleteIds } }); - - await goToPageTwo(); - - const [selectAll] = screen.getAllByRole('checkbox'); - await user.click(selectAll); - await user.click(screen.getByRole('button', { name: 'Delete' })); - - expect(onDeleteIds).toHaveBeenCalledWith(['d', 'e', 'f']); - }); -}); diff --git a/src/lib/components/__tests__/multiSelectTableHarness.svelte b/src/lib/components/__tests__/multiSelectTableHarness.svelte deleted file mode 100644 index d8da677b6d..0000000000 --- a/src/lib/components/__tests__/multiSelectTableHarness.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - { - onDeleteIds([...selectedRows]); - return { deleted: [] }; - }}> - {#snippet header(root)} - Id - {/snippet} - - {#snippet children(root)} - - {#each rows as id} - - {id} - - {/each} - {/snippet} - diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index e2866666a9..88edcb4580 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -1,33 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { beforeAll, vi } from 'vitest'; -// jsdom hands back a bare object for web storage under this runtime, so the -// console's `localStorage` reads blow up before any component renders -function createStorage(): Storage { - const entries = new Map(); - - return { - get length() { - return entries.size; - }, - key: (index: number) => [...entries.keys()][index] ?? null, - getItem: (key: string) => entries.get(key) ?? null, - setItem: (key: string, value: string) => void entries.set(key, String(value)), - removeItem: (key: string) => void entries.delete(key), - clear: () => entries.clear() - }; -} - -for (const name of ['localStorage', 'sessionStorage'] as const) { - if (typeof window[name]?.getItem === 'function') continue; - - Object.defineProperty(window, name, { - writable: true, - configurable: true, - value: createStorage() - }); -} - // required for svelte5 + jsdom as jsdom does not support matchMedia Object.defineProperty(window, 'matchMedia', { writable: true, @@ -42,17 +15,6 @@ Object.defineProperty(window, 'matchMedia', { })) }); -// jsdom ships no ResizeObserver, which pink-svelte's FloatingActionBar constructs on mount -Object.defineProperty(window, 'ResizeObserver', { - writable: true, - configurable: true, - value: class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } -}); - beforeAll(() => { vi.mock('$app/environment', () => ({ dev: true,