Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-row-cell-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@human-kit/ui': patch
---

Scrolling a wide virtualized table no longer stalls. A row kept the order of its cells in a `SvelteSet` and a `SvelteMap`, and rewrote the index of every cell after one that left, so a row of fourteen columns leaving the viewport made around a hundred reactive writes. The order now lives in a plain array, read with `indexOf`, and the row's existing epoch announces when a removal moves the cells after it.
40 changes: 40 additions & 0 deletions packages/ui/src/lib/table/root/table-drop-column-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script lang="ts">
import { Table } from '../index';

type DemoRow = { id: string; email: string; group: string; role: string };
type ColumnId = 'email' | 'group' | 'role';

const rows: DemoRow[] = [
{ id: 'danilo', email: 'danilo@example.com', group: 'Developer', role: 'Owner' },
{ id: 'zahra', email: 'zahra@example.com', group: 'Admin', role: 'Viewer' }
];

let withGroup = $state(true);
const columns = $derived<ColumnId[]>(withGroup ? ['email', 'group', 'role'] : ['email', 'role']);
</script>

<Table.Root aria-label="Drop column table">
<Table.Header>
<Table.Row>
{#each columns as column (column)}
<Table.Column id={column} rowHeader={column === 'email'} textValue={column}>
<Table.ColumnHeaderCell data-testid={`${column}-header`}>{column}</Table.ColumnHeaderCell>
</Table.Column>
{/each}
</Table.Row>
</Table.Header>

<Table.Body>
{#each rows as row (row.id)}
<Table.Row id={row.id}>
{#each columns as column (column)}
<Table.Cell data-testid={`${column}-cell-${row.id}`}>{row[column]}</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

<button type="button" data-testid="drop-group" onclick={() => (withGroup = false)}
>Drop group</button
>
18 changes: 18 additions & 0 deletions packages/ui/src/lib/table/root/table-root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import TableTest from './table-test.svelte';
import TableReorderTest from './table-reorder-test.svelte';
import TableDropColumnTest from './table-drop-column-test.svelte';
import { expectNoFalseFocusAttributes } from '../../test-utils/focus-contract';

function getHeaderCells(container: HTMLElement) {
Expand Down Expand Up @@ -1266,4 +1267,21 @@ describe('Table.Root', () => {
getBodySecondColumnCells(document.body).map((cell) => cell.textContent?.trim())
).toContain('Admin');
});

// A row keeps the order of its cells in plain arrays, announced by an epoch: a cell that
// leaves from the middle has to move the ones after it down, in the header and the body.
it('moves the cells after a removed one down a column', async () => {
render(TableDropColumnTest);
const index = (testId: string) =>
document.querySelector(`[data-testid="${testId}"]`)?.getAttribute('data-column-index');

expect(index('role-header')).toBe('2');
expect(index('role-cell-danilo')).toBe('2');

document.querySelector<HTMLElement>('[data-testid="drop-group"]')?.click();

await expect.poll(() => index('role-header')).toBe('1');
await expect.poll(() => index('role-cell-danilo')).toBe('1');
await expect.poll(() => index('role-cell-zahra')).toBe('1');
});
});
24 changes: 11 additions & 13 deletions packages/ui/src/lib/table/row/table-row.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import {
setTableRowContext,
useTableContext,
Expand All @@ -24,9 +23,12 @@
const table = useTableContext();
const section = useTableSectionContext();
const rowToken = table.createInstanceToken('row');
// Plain, not reactive: the epoch below is what announces a change. These used to be a
// `SvelteSet` and a `SvelteMap` whose indexes were rewritten for every cell after the one that
// left, so a virtualized row of fourteen columns leaving the viewport made ~100 reactive writes
// — the whole cost of scrolling a wide table. A row has a handful of cells: `indexOf` over them
// is cheaper than keeping an index map in step.
const cellOrder: string[] = [];
const cellOrderSet = new SvelteSet<string>();
const cellIndexByToken = new SvelteMap<string, number>();
const cellElements: Record<string, () => HTMLElement | undefined> = {};
// Counter-based on purpose: cell indexes are resolved against the live DOM
// (`rowElement.children`), so order changes are event-like (registration /
Expand Down Expand Up @@ -101,10 +103,8 @@
}

function registerCellToken(token: string, getElement?: () => HTMLElement | undefined) {
const alreadyOrdered = cellOrderSet.has(token);
const alreadyOrdered = cellOrder.includes(token);
if (!alreadyOrdered) {
cellOrderSet.add(token);
cellIndexByToken.set(token, cellOrder.length);
cellOrder.push(token);
}
if (getElement) {
Expand All @@ -125,17 +125,15 @@

function unregisterCellToken(token: string) {
delete cellElements[token];
const index = cellIndexByToken.get(token) ?? -1;
const index = cellOrder.indexOf(token);
if (index >= 0) {
cellOrder.splice(index, 1);
cellOrderSet.delete(token);
cellIndexByToken.delete(token);
for (let nextIndex = index; nextIndex < cellOrder.length; nextIndex += 1) {
cellIndexByToken.set(cellOrder[nextIndex], nextIndex);
}
if (isBodyRow && !tracksDynamicBodyCellOrder) {
if (bodyCellOrderSealed) {
promoteBodyCellOrderTracking();
} else if (index < cellOrder.length) {
// The cells after it moved down one: the index map used to say so by itself.
notifyCellOrderChange();
}
return;
}
Expand All @@ -150,7 +148,7 @@
const index = Array.from(rowElement.children).indexOf(element);
if (index >= 0) return index;
}
return cellIndexByToken.get(token) ?? -1;
return cellOrder.indexOf(token);
}

setTableRowContext({
Expand Down
Loading