perf(frontend): round 6 — coalesced progressive listing, in-place SvelteSet, batch fan-out, t() value cache
Four SPA hot-path fixes, each shipping with a vitest benchmark gate
(verbatim BEFORE replica + equivalence + perf assertion) so CI
re-verifies the win on every run:
- fetchFolderListing invoked onPage after EVERY 200-row page with the
whole accumulated listing, and the files view re-sorts everything per
emission — O(N²/page) main-thread work on large folders. Page one and
the final page always emit; intermediates coalesce to one per 150 ms.
25×200 load: 30.9 → 4.0 ms (7.8x), 65 000 → 5 200 sorted elements.
- selected/favoriteIds/sharedIds (files) and favoriteIds (recent) were
$state<Set>s copied whole on every toggle. Now one SvelteSet each,
mutated in place (the useSelection pattern): 1 000 toggles @ N=5 000
771.9 → 1.9 ms (399x); one-toggle fan-out across 40 mounted rows
40 → 3 re-runs when refining a select-all.
- batchDelete/moveInto awaited one request per item serially and
probed listing.folders.find per id (O(N·M)). Now an id index built
once + mapLimit(6) fan-out, failure semantics preserved: 100-item
delete @ 5 ms RTT 525 → 89 ms (5.9x), 38 825 → 500 probes.
- t() re-split its dotted key and walked the nested dict on every call,
and interpolate regex-scanned strings without placeholders. Resolved
values now memoize per (dict, key) in a WeakMap + a {{ guard:
20k mixed calls 22.7 → 8.6 ms (2.63x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
typeLabel
|
||||
} from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { replaceSet } from '$lib/utils/sets';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
import { gridColumns } from '$lib/utils/grid';
|
||||
import {
|
||||
@@ -166,8 +167,11 @@
|
||||
// Favorite + shared badge sets for the current folder, seeded directly from
|
||||
// the listing response (server-computed, scoped to these items — no extra
|
||||
// per-navigation fetch) and updated optimistically on mutation.
|
||||
let favoriteIds = $state<Set<string>>(new Set());
|
||||
let sharedIds = $state<Set<string>>(new Set());
|
||||
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
|
||||
// the whole set, and every other present-key `.has()` reader is spared
|
||||
// (measured in selectionPatterns.bench.test.ts).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
const sharedIds = new SvelteSet<string>();
|
||||
|
||||
function openMove(kind: ItemType, id: string, name: string) {
|
||||
actionTarget = { id, name, kind };
|
||||
@@ -189,19 +193,15 @@
|
||||
async function toggleFavorite(kind: ItemType, id: string) {
|
||||
const isFav = favoriteIds.has(id);
|
||||
// Optimistic toggle, reverted on failure.
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(id);
|
||||
else next.add(id);
|
||||
favoriteIds = next;
|
||||
if (isFav) favoriteIds.delete(id);
|
||||
else favoriteIds.add(id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(kind, id);
|
||||
else await addFavorite(kind, id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
const reverted = new SvelteSet(favoriteIds);
|
||||
if (isFav) reverted.add(id);
|
||||
else reverted.delete(id);
|
||||
favoriteIds = reverted;
|
||||
if (isFav) favoriteIds.add(id);
|
||||
else favoriteIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +229,8 @@
|
||||
|
||||
function applyListing(data: FolderListing) {
|
||||
listing = data;
|
||||
favoriteIds = new Set(data.favoriteIds);
|
||||
sharedIds = new Set(data.sharedIds);
|
||||
replaceSet(favoriteIds, data.favoriteIds);
|
||||
replaceSet(sharedIds, data.sharedIds);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -881,19 +881,20 @@
|
||||
}
|
||||
|
||||
// ── Multi-select + batch ────────────────────────────────────────────────
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
// In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares
|
||||
// the other selected rows' `has()` readers — decisive when refining a
|
||||
// select-all (selectionPatterns.bench.test.ts).
|
||||
const selected = new SvelteSet<string>();
|
||||
// Anchor row id for shift-click range selection.
|
||||
let selectionAnchor = $state<string | null>(null);
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
selectionAnchor = id;
|
||||
}
|
||||
function clearSelection() {
|
||||
selected = new Set();
|
||||
selected.clear();
|
||||
selectionAnchor = null;
|
||||
}
|
||||
|
||||
@@ -911,7 +912,7 @@
|
||||
const b = orderedIds.indexOf(id);
|
||||
if (a !== -1 && b !== -1) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
selected = new Set([...selected, ...orderedIds.slice(lo, hi + 1)]);
|
||||
for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -927,11 +928,16 @@
|
||||
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === totalCount) clearSelection();
|
||||
// Select-all only picks what the user can see — dotfiles hidden
|
||||
// by the current filter are excluded so "select all → delete"
|
||||
// can't accidentally sweep up hidden files the user never saw.
|
||||
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
|
||||
if (selected.size === totalCount) {
|
||||
clearSelection();
|
||||
} else {
|
||||
// Select-all only picks what the user can see — dotfiles hidden
|
||||
// by the current filter are excluded so "select all → delete"
|
||||
// can't accidentally sweep up hidden files the user never saw.
|
||||
selected.clear();
|
||||
for (const i of visibleFolders) selected.add(i.id);
|
||||
for (const i of visibleFiles) selected.add(i.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -948,9 +954,12 @@
|
||||
async function batchDownload() {
|
||||
const fileIds: string[] = [];
|
||||
const folderIds: string[] = [];
|
||||
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
|
||||
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
||||
const fileIdSet = new Set(listing.files.map((f) => f.id));
|
||||
for (const id of selected) {
|
||||
if (listing.folders.some((f) => f.id === id)) folderIds.push(id);
|
||||
else if (listing.files.some((f) => f.id === id)) fileIds.push(id);
|
||||
if (folderIdSet.has(id)) folderIds.push(id);
|
||||
else if (fileIdSet.has(id)) fileIds.push(id);
|
||||
}
|
||||
if (fileIds.length === 0 && folderIds.length === 0) return;
|
||||
|
||||
@@ -1009,7 +1018,7 @@
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
||||
for (const it of items) favoriteIds.add(it.id);
|
||||
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
@@ -1018,13 +1027,14 @@
|
||||
}
|
||||
|
||||
function selectionTargets(): ActionTarget[] {
|
||||
// One O(M) index build instead of an O(N·M) `find` per selected id.
|
||||
// Folders win id collisions, matching the old folder-first probe.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
|
||||
const byId = new Map<string, ActionTarget>();
|
||||
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
||||
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
||||
return [...selected]
|
||||
.map((id) => {
|
||||
const folder = listing.folders.find((f) => f.id === id);
|
||||
if (folder) return { id, name: folder.name, kind: 'folder' as ItemType };
|
||||
const file = listing.files.find((f) => f.id === id);
|
||||
return file ? { id, name: file.name, kind: 'file' as ItemType } : null;
|
||||
})
|
||||
.map((id) => byId.get(id) ?? null)
|
||||
.filter((x): x is ActionTarget => x !== null);
|
||||
}
|
||||
|
||||
@@ -1070,15 +1080,19 @@
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
for (const id of ids) {
|
||||
const folder = listing.folders.find((f) => f.id === id);
|
||||
// Bounded fan-out instead of a serial await per item: 100 deletes at
|
||||
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
|
||||
// windows. Failures toast individually and the rest still proceed,
|
||||
// exactly like the old serial loop.
|
||||
const folderIdSet = new Set(listing.folders.map((f) => f.id));
|
||||
await mapLimit(ids, 6, async (id) => {
|
||||
try {
|
||||
if (folder) await deleteFolder(id);
|
||||
if (folderIdSet.has(id)) await deleteFolder(id);
|
||||
else await deleteFile(id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
clearSelection();
|
||||
await reload();
|
||||
void session.refresh();
|
||||
@@ -1185,16 +1199,26 @@
|
||||
async function moveInto(targetFolderId: string, e: DragEvent) {
|
||||
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
||||
if (items.length === 0) return;
|
||||
try {
|
||||
for (const it of items) {
|
||||
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
||||
else await moveFolder(it.id, targetFolderId);
|
||||
}
|
||||
clearSelection();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
errorToast(err);
|
||||
// Bounded fan-out (was a serial await per item). Every item is
|
||||
// attempted; on any failure the first error is surfaced and the
|
||||
// selection is kept so the drop can be retried, like the old loop.
|
||||
const failures = (
|
||||
await mapLimit(items, 6, async (it) => {
|
||||
try {
|
||||
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
||||
else await moveFolder(it.id, targetFolderId);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err ?? new Error('move failed');
|
||||
}
|
||||
})
|
||||
).filter((err) => err !== null);
|
||||
if (failures.length > 0) {
|
||||
errorToast(failures[0]);
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
await reload();
|
||||
}
|
||||
|
||||
function onFolderDrop(e: DragEvent, folder: FolderItem) {
|
||||
@@ -2244,11 +2268,7 @@
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
{@const ShareDialog = shareDialog.component}
|
||||
<ShareDialog
|
||||
bind:open={shareOpen}
|
||||
item={actionTarget}
|
||||
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
|
||||
/>
|
||||
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
|
||||
{/if}
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the files view's batch-operation rework
|
||||
* (`batchDelete` / `moveInto` / `selectionTargets` / `batchDownload` in
|
||||
* `[...path]/+page.svelte`).
|
||||
*
|
||||
* Audit finding: multi-item delete/move awaited one request per item in a
|
||||
* serial loop — at ~30 ms RTT a 100-item delete is ~3 s of waterfall — and
|
||||
* every per-id classification ran `listing.folders.find(...)` /
|
||||
* `listing.files.some(...)`, an O(N·M) scan over the listing per selected id.
|
||||
* The fix builds an id index once (O(M)) and fans the requests out through
|
||||
* the view's existing `mapLimit` with 6 in flight.
|
||||
*
|
||||
* The functions are component-internal, so — like the Rust bench modules that
|
||||
* replicate handler internals — this bench replicates BEFORE verbatim and
|
||||
* AFTER (index + `mapLimit`, the exact shapes now in the component) against a
|
||||
* stubbed per-item endpoint with simulated latency.
|
||||
*
|
||||
* Gates: (1) both arms attempt the identical (id, kind) operation set —
|
||||
* folder-first classification preserved; (2) a 100-item batch at 5 ms
|
||||
* simulated RTT completes ≥3x faster; (3) the classification scan count
|
||||
* drops from O(N·M) to one pass.
|
||||
*/
|
||||
|
||||
const M = 2_000; // listing size
|
||||
const N = 100; // selection size
|
||||
const RTT_MS = 5;
|
||||
|
||||
const listing = {
|
||||
folders: Array.from({ length: M / 4 }, (_, i) => ({ id: `d-${i}`, name: `dir ${i}` })),
|
||||
files: Array.from({ length: (3 * M) / 4 }, (_, i) => ({ id: `f-${i}`, name: `file ${i}` }))
|
||||
};
|
||||
// Selection interleaves folders and files, like a shift-range over a mixed view.
|
||||
const selectedIds = [
|
||||
...listing.folders.slice(40, 40 + N / 4).map((f) => f.id),
|
||||
...listing.files.slice(900, 900 + (3 * N) / 4).map((f) => f.id)
|
||||
];
|
||||
|
||||
/** Stubbed per-item endpoint: RTT_MS latency, records the attempted op. */
|
||||
function makeOps() {
|
||||
const attempted: Array<{ id: string; kind: 'file' | 'folder' }> = [];
|
||||
let comparisons = 0;
|
||||
return {
|
||||
attempted,
|
||||
countCmp: () => comparisons++,
|
||||
get comparisons() {
|
||||
return comparisons;
|
||||
},
|
||||
deleteFolder: async (id: string) => {
|
||||
attempted.push({ id, kind: 'folder' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
},
|
||||
deleteFile: async (id: string) => {
|
||||
attempted.push({ id, kind: 'file' });
|
||||
await new Promise((r) => setTimeout(r, RTT_MS));
|
||||
}
|
||||
};
|
||||
}
|
||||
type Ops = ReturnType<typeof makeOps>;
|
||||
|
||||
/** BEFORE, verbatim shape: serial await + `find` per id. */
|
||||
async function batchDeleteBefore(ids: string[], ops: Ops): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const folder = listing.folders.find((f) => {
|
||||
ops.countCmp();
|
||||
return f.id === id;
|
||||
});
|
||||
if (folder) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The view's `mapLimit`, verbatim. */
|
||||
async function mapLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** AFTER, verbatim shape: one O(M) index pass + bounded fan-out of 6. */
|
||||
async function batchDeleteAfter(ids: string[], ops: Ops): Promise<void> {
|
||||
const folderIdSet = new Set(
|
||||
listing.folders.map((f) => {
|
||||
ops.countCmp();
|
||||
return f.id;
|
||||
})
|
||||
);
|
||||
await mapLimit(ids, 6, async (id) => {
|
||||
if (folderIdSet.has(id)) await ops.deleteFolder(id);
|
||||
else await ops.deleteFile(id);
|
||||
});
|
||||
}
|
||||
|
||||
const opKey = (o: { id: string; kind: string }) => `${o.kind}:${o.id}`;
|
||||
|
||||
describe('files-view batch operations (benchmark gate)', () => {
|
||||
it(
|
||||
'both arms attempt the identical operation set, ≥3x faster fanned out',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const before = makeOps();
|
||||
const t0 = performance.now();
|
||||
await batchDeleteBefore(selectedIds, before);
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
const after = makeOps();
|
||||
const t1 = performance.now();
|
||||
await batchDeleteAfter(selectedIds, after);
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
// Equivalence: same ops, same folder/file classification. Order is
|
||||
// not part of the contract (the ops are independent single-item
|
||||
// endpoints); compare as sets and sizes.
|
||||
expect(after.attempted.length).toBe(before.attempted.length);
|
||||
expect(new Set(after.attempted.map(opKey))).toEqual(new Set(before.attempted.map(opKey)));
|
||||
expect(before.attempted.filter((o) => o.kind === 'folder').length).toBe(N / 4);
|
||||
|
||||
// Scan work: O(N·M) probes collapse to one O(M) pass.
|
||||
expect(after.comparisons).toBe(listing.folders.length);
|
||||
expect(before.comparisons).toBeGreaterThan(after.comparisons * 10);
|
||||
|
||||
console.info(
|
||||
`batch delete ${N} items @ ${RTT_MS} ms RTT: serial ${beforeMs.toFixed(0)} ms (${before.comparisons} id probes) vs mapLimit(6) ${afterMs.toFixed(0)} ms (${after.comparisons} probes) — ${(beforeMs / afterMs).toFixed(1)}x`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 3);
|
||||
}
|
||||
);
|
||||
|
||||
it('selectionTargets index matches the per-id find, folder-first on collision', () => {
|
||||
// BEFORE: folder probed first per id. AFTER: files inserted first so
|
||||
// folders overwrite → folder wins collisions. Same observable result.
|
||||
const shadow = { id: listing.files[0].id, name: 'shadow-folder' };
|
||||
const foldersPlus = [...listing.folders, shadow];
|
||||
const wanted = [shadow.id, listing.folders[5].id, listing.files[10].id, 'missing-id'];
|
||||
|
||||
const beforeTargets = wanted
|
||||
.map((id) => {
|
||||
const folder = foldersPlus.find((f) => f.id === id);
|
||||
if (folder) return { id, name: folder.name, kind: 'folder' as const };
|
||||
const file = listing.files.find((f) => f.id === id);
|
||||
return file ? { id, name: file.name, kind: 'file' as const } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
const byId = new Map<string, { id: string; name: string; kind: 'file' | 'folder' }>();
|
||||
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
|
||||
for (const f of foldersPlus) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
|
||||
const afterTargets = wanted
|
||||
.map((id) => byId.get(id) ?? null)
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
expect(afterTargets).toEqual(beforeTargets);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
||||
import { replaceSet } from '$lib/utils/sets';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
let raw = $state<RecentResourceItem[]>([]);
|
||||
@@ -37,7 +38,9 @@
|
||||
let groupBy = $state('');
|
||||
let reversed = $state(false);
|
||||
const owners = useOwnerCache(resolveOwnerName);
|
||||
let favoriteIds = $state<Set<string>>(new Set());
|
||||
// In-place reactive set — a star toggle skips the full-set copy and
|
||||
// spares the other favorited rows' readers.
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||
|
||||
@@ -109,7 +112,10 @@
|
||||
async function loadFavoriteIds() {
|
||||
try {
|
||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
||||
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
|
||||
replaceSet(
|
||||
favoriteIds,
|
||||
favs.items.map((f) => f.resource.id)
|
||||
);
|
||||
} catch {
|
||||
// non-fatal — stars just default to off
|
||||
}
|
||||
@@ -169,18 +175,15 @@
|
||||
|
||||
async function toggleFavorite(entry: ResourceEntry) {
|
||||
const isFav = favoriteIds.has(entry.id);
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
favoriteIds = next;
|
||||
// Optimistic in-place toggle, reverted on failure.
|
||||
if (isFav) favoriteIds.delete(entry.id);
|
||||
else favoriteIds.add(entry.id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(entry.kind, entry.id);
|
||||
else await addFavorite(entry.kind, entry.id);
|
||||
} catch (e) {
|
||||
// revert on failure
|
||||
favoriteIds = isFav
|
||||
? new Set([...favoriteIds, entry.id])
|
||||
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
||||
if (isFav) favoriteIds.add(entry.id);
|
||||
else favoriteIds.delete(entry.id);
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user