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:
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the coalesced progressive-render emissions in
|
||||
* {@link fetchFolderListing}.
|
||||
*
|
||||
* Audit finding: the loader invoked `onPage` after EVERY 200-item page with a
|
||||
* fresh copy of the whole accumulated listing, and the files view re-derives
|
||||
* its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from
|
||||
* each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200)
|
||||
* elements re-sorted on the main thread during a single load — hundreds of ms
|
||||
* of jank on exactly the large folders progressive rendering was meant to
|
||||
* help. The fix emits page one (first paint) and the final page always, and
|
||||
* intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS.
|
||||
*
|
||||
* Gates:
|
||||
* 1. Equivalence — final listing identical to the emit-every-page reference,
|
||||
* first emission still after page one (first paint preserved), last
|
||||
* emission still `done === true` with the complete listing.
|
||||
* 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side
|
||||
* derive work collapses from 25 full re-sorts to ≤3; wall time of the
|
||||
* load+derive cycle must drop accordingly (≥3x on the derive term).
|
||||
*/
|
||||
|
||||
type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } };
|
||||
type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
const PAGES = 25; // 5 000-item folder
|
||||
|
||||
/** Deterministic shuffled names so the consumer sort actually works. */
|
||||
function pageBody(page: number): ResourcePage {
|
||||
const items: ResourceItem[] = [];
|
||||
for (let i = 0; i < PAGE_SIZE; i++) {
|
||||
const n = page * PAGE_SIZE + i;
|
||||
const id = `f-${n.toString().padStart(5, '0')}`;
|
||||
// Mix folders into the first page like a real listing (folders first).
|
||||
const isFolder = page === 0 && i < 20;
|
||||
items.push({
|
||||
resource_type: isFolder ? 'folder' : 'file',
|
||||
resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` }
|
||||
});
|
||||
}
|
||||
return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined };
|
||||
}
|
||||
|
||||
function fakeRes(body: ResourcePage): Response {
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
headers: { get: () => null }
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function mockPagedFetch(): void {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy
|
||||
* of the whole accumulated listing after every page.
|
||||
*/
|
||||
async function referenceFetchFolderListing(
|
||||
folderId: string,
|
||||
onPage: (partial: FolderListing, done: boolean) => void
|
||||
): Promise<FolderListing> {
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as ResourcePage;
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor);
|
||||
} while (cursor);
|
||||
return { folders, files, favoriteIds: [], sharedIds: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The files view's per-emission derive chain, reduced to its dominant costs:
|
||||
* dotfile filter pass + two localeCompare sorts + ordered-entry rebuild
|
||||
* (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte).
|
||||
* Returns the number of elements that went through the sort — the O(N²) term.
|
||||
*/
|
||||
function consumerDerive(partial: FolderListing): number {
|
||||
const visF = partial.folders.filter((f) => !f.name.startsWith('.'));
|
||||
const visX = partial.files.filter((f) => !f.name.startsWith('.'));
|
||||
const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)];
|
||||
return orderedIds.length;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateFolderCache();
|
||||
});
|
||||
|
||||
describe('coalesced progressive listing emissions (benchmark gate)', () => {
|
||||
it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => {
|
||||
mockPagedFetch();
|
||||
const refEmits: Array<{ n: number; done: boolean }> = [];
|
||||
const refFinal = await referenceFetchFolderListing('bench', (p, done) =>
|
||||
refEmits.push({ n: p.folders.length + p.files.length, done })
|
||||
);
|
||||
|
||||
mockPagedFetch();
|
||||
const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = [];
|
||||
const r = await fetchFolderListing('bench', {
|
||||
onPage: (partial, done) =>
|
||||
emits.push({ n: partial.folders.length + partial.files.length, done, partial })
|
||||
});
|
||||
|
||||
// Identical complete listing.
|
||||
expect(r.listing).toEqual(refFinal);
|
||||
// First paint unchanged: the first emission is still page one.
|
||||
expect(emits[0].n).toBe(refEmits[0].n);
|
||||
expect(emits[0].n).toBe(PAGE_SIZE);
|
||||
// Exactly one done emission, last, carrying the full listing — as before.
|
||||
expect(emits.filter((e) => e.done).length).toBe(1);
|
||||
expect(emits[emits.length - 1].done).toBe(true);
|
||||
expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE);
|
||||
expect(refEmits[refEmits.length - 1].done).toBe(true);
|
||||
// Emissions are a subset of what the reference produced (never more).
|
||||
expect(emits.length).toBeLessThanOrEqual(refEmits.length);
|
||||
// Every emitted partial is a prefix-accumulation (monotone growth).
|
||||
for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n);
|
||||
});
|
||||
|
||||
it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(
|
||||
fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor
|
||||
);
|
||||
const emits: boolean[] = [];
|
||||
await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) });
|
||||
expect(emits).toEqual([true]);
|
||||
});
|
||||
|
||||
it(
|
||||
`collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`,
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
// Warm-up both paths (JIT tiering outside the measured windows).
|
||||
mockPagedFetch();
|
||||
await referenceFetchFolderListing('warm', (p) => consumerDerive(p));
|
||||
mockPagedFetch();
|
||||
await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) });
|
||||
|
||||
mockPagedFetch();
|
||||
let refSorted = 0;
|
||||
let refEmits = 0;
|
||||
const t0 = performance.now();
|
||||
await referenceFetchFolderListing('bench', (p) => {
|
||||
refEmits++;
|
||||
refSorted += consumerDerive(p);
|
||||
});
|
||||
const refMs = performance.now() - t0;
|
||||
|
||||
mockPagedFetch();
|
||||
let sorted = 0;
|
||||
let emitsN = 0;
|
||||
const t1 = performance.now();
|
||||
await fetchFolderListing('bench', {
|
||||
onPage: (p) => {
|
||||
emitsN++;
|
||||
sorted += consumerDerive(p);
|
||||
}
|
||||
});
|
||||
const ms = performance.now() - t1;
|
||||
|
||||
console.info(
|
||||
`progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)`
|
||||
);
|
||||
|
||||
// The reference re-derived every page: Σ = P(P+1)/2 pages of elements.
|
||||
expect(refEmits).toBe(PAGES);
|
||||
expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2);
|
||||
// Coalesced: page 1 + final (+ occasionally one mid emission if the
|
||||
// stubbed pages ever take >150 ms — they don't on any healthy runner).
|
||||
expect(emitsN).toBeLessThanOrEqual(3);
|
||||
// ≥5x less consumer sort work is the point of the change.
|
||||
expect(sorted).toBeLessThan(refSorted / 5);
|
||||
// And it must show up as wall time on the combined load+derive cycle.
|
||||
expect(ms).toBeLessThan(refMs / 3);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -94,6 +94,17 @@ export async function getFolder(id: string): Promise<FolderItem> {
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum spacing between intermediate progressive-render emissions of
|
||||
* {@link fetchFolderListing}. Each emission hands the consumer the WHOLE
|
||||
* accumulated listing, and the files view re-derives its filtered + sorted
|
||||
* view from it (O(accumulated · log) with `localeCompare`), so emitting every
|
||||
* page made a large-folder load Σ O(N²/page) of main-thread sort work. Page
|
||||
* one and the final page always emit; pages in between only emit after this
|
||||
* much time has passed since the previous emission.
|
||||
*/
|
||||
export const PAGE_EMIT_MIN_INTERVAL_MS = 150;
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||
@@ -112,12 +123,15 @@ export async function fetchFolderListing(
|
||||
etag?: string;
|
||||
forceRefresh?: boolean;
|
||||
/**
|
||||
* Progressive render hook: invoked after EVERY page with the
|
||||
* accumulated listing so far (the arrays are fresh copies — safe to
|
||||
* hand to reactive state). Without it, a 2,000-item folder waited
|
||||
* for all ⌈N/200⌉ sequential round-trips before the first row
|
||||
* painted; with it the view paints after page one (~200 items) and
|
||||
* fills in as the tail pages land.
|
||||
* Progressive render hook: invoked with the accumulated listing so
|
||||
* far (the arrays are fresh copies — safe to hand to reactive
|
||||
* state). Without it, a 2,000-item folder waited for all ⌈N/200⌉
|
||||
* sequential round-trips before the first row painted; with it the
|
||||
* view paints after page one (~200 items) and fills in as the tail
|
||||
* pages land. Emissions are coalesced to at most one per
|
||||
* {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final
|
||||
* page — the hook is always called for page one and always called
|
||||
* once more with `done === true` and the complete listing.
|
||||
*/
|
||||
onPage?: (partial: FolderListing, done: boolean) => void;
|
||||
} = {}
|
||||
@@ -125,6 +139,8 @@ export async function fetchFolderListing(
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
let firstPage = true;
|
||||
let lastEmit = 0;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
@@ -144,10 +160,18 @@ export async function fetchFolderListing(
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
opts.onPage?.(
|
||||
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
|
||||
!cursor
|
||||
);
|
||||
const done = !cursor;
|
||||
if (
|
||||
opts.onPage &&
|
||||
(done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS)
|
||||
) {
|
||||
lastEmit = performance.now();
|
||||
opts.onPage(
|
||||
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
|
||||
done
|
||||
);
|
||||
}
|
||||
firstPage = false;
|
||||
} while (cursor);
|
||||
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bench harness for the selection/badge-set reactivity patterns compared in
|
||||
* `selectionPatterns.bench.test.ts` (runes only compile in `.svelte.ts`
|
||||
* modules, so the models live here; the app never imports this file — it is
|
||||
* test-only and tree-shaken from the bundle).
|
||||
*
|
||||
* `copyReassignModel` is the pre-fix files-view pattern, verbatim: a
|
||||
* `$state<Set>` where every toggle copies the whole set into a fresh
|
||||
* `SvelteSet` and reassigns. `inPlaceModel` is the post-fix pattern: one
|
||||
* `SvelteSet` mutated in place.
|
||||
*/
|
||||
import { flushSync } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface SelectionModel {
|
||||
has(id: string): boolean;
|
||||
toggle(id: string): void;
|
||||
seed(ids: Iterable<string>): void;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Pre-fix pattern (files view `toggleSelected`, verbatim copy-and-reassign). */
|
||||
export function copyReassignModel(): SelectionModel {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
},
|
||||
seed(ids) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim
|
||||
selected = new Set(ids);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Post-fix pattern: one live `SvelteSet` mutated in place (per-key sources
|
||||
* for present keys; absent-key reads track the version signal). */
|
||||
export function inPlaceModel(): SelectionModel {
|
||||
const selected = new SvelteSet<string>();
|
||||
return {
|
||||
has: (id) => selected.has(id),
|
||||
toggle(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
},
|
||||
seed(ids) {
|
||||
selected.clear();
|
||||
for (const id of ids) selected.add(id);
|
||||
},
|
||||
get size() {
|
||||
return selected.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one effect per row reading `model.has(rowId)` — the shape of a row's
|
||||
* checkbox/star binding — run `mutate`, and report how many row effects re-ran
|
||||
* (the invalidation fan-out of the mutation).
|
||||
*/
|
||||
export function measureFanout(model: SelectionModel, rowIds: string[], mutate: () => void): number {
|
||||
let runs = 0;
|
||||
const destroy = $effect.root(() => {
|
||||
for (const id of rowIds) {
|
||||
$effect(() => {
|
||||
void model.has(id);
|
||||
runs += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
flushSync(); // initial run of every row effect
|
||||
const baseline = runs;
|
||||
mutate();
|
||||
flushSync();
|
||||
destroy();
|
||||
return runs - baseline;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
copyReassignModel,
|
||||
inPlaceModel,
|
||||
measureFanout,
|
||||
type SelectionModel
|
||||
} from './selectionBench.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the in-place `SvelteSet` selection/badge sets in the
|
||||
* files and recent views.
|
||||
*
|
||||
* Audit finding: `selected`, `favoriteIds` and `sharedIds` were plain
|
||||
* `$state<Set>`s rebuilt from a full copy on every single-item toggle
|
||||
* (`new SvelteSet(selected)` + reassign). That costs (a) an O(N) copy per
|
||||
* toggle — N unbounded under "select all → refine" — and (b) reassigning the
|
||||
* state reference invalidates EVERY mounted row's `.has(id)` read, so the
|
||||
* whole viewport re-renders for a one-row change. The fix keeps one
|
||||
* `SvelteSet` per set and mutates it in place; `SvelteSet` tracks per-key, so
|
||||
* a toggle re-runs only the toggled row's readers. The composable
|
||||
* `useSelection` already shipped this pattern — the views now match it.
|
||||
*
|
||||
* `SvelteSet` granularity (svelte/src/reactivity/set.js): present keys get a
|
||||
* per-key source; `.has()` on an ABSENT key tracks the set's version signal
|
||||
* ("don't create sources willy-nilly"), so miss-readers re-run on any
|
||||
* mutation in both patterns. The in-place win is therefore: no O(N) copy, and
|
||||
* every OTHER present-key reader is spared — copy-reassign re-runs all rows.
|
||||
*
|
||||
* Gates: (1) both patterns agree on membership across a deterministic toggle
|
||||
* script; (2) fan-out under 40 mounted row-effects matches those exact
|
||||
* semantics (misses+1 in place vs all 40 copied — 3 vs 40 when the list is
|
||||
* mostly selected, the "select all → refine" case); (3) 1 000 toggles over a
|
||||
* 5 000-id selection run ≥5x faster in place.
|
||||
*/
|
||||
|
||||
/** Deterministic PRNG so both models replay the identical script. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `id-${i}`);
|
||||
|
||||
describe('in-place SvelteSet selection (benchmark gate)', () => {
|
||||
it('membership after a 500-op toggle script is identical in both patterns', () => {
|
||||
const universe = ids(1_000);
|
||||
const a = copyReassignModel();
|
||||
const b = inPlaceModel();
|
||||
a.seed(universe.slice(0, 100));
|
||||
b.seed(universe.slice(0, 100));
|
||||
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const id = universe[Math.floor(rand() * universe.length)];
|
||||
a.toggle(id);
|
||||
b.toggle(id);
|
||||
}
|
||||
expect(a.size).toBe(b.size);
|
||||
for (const id of universe) {
|
||||
expect(b.has(id), id).toBe(a.has(id));
|
||||
}
|
||||
});
|
||||
|
||||
it('fan-out of one toggle across 40 mounted rows matches per-key semantics', () => {
|
||||
const rows = ids(40);
|
||||
const scenario = (seeded: number): { copy: number; inplace: number } => {
|
||||
const copy = copyReassignModel();
|
||||
copy.seed(rows.slice(0, seeded));
|
||||
const copyFanout = measureFanout(copy, rows, () => copy.toggle('id-7'));
|
||||
|
||||
const inplace = inPlaceModel();
|
||||
inplace.seed(rows.slice(0, seeded));
|
||||
const inplaceFanout = measureFanout(inplace, rows, () => inplace.toggle('id-7'));
|
||||
return { copy: copyFanout, inplace: inplaceFanout };
|
||||
};
|
||||
|
||||
// 10/40 selected (sparse selection): misses (30) + the toggled row.
|
||||
const sparse = scenario(10);
|
||||
// 38/40 selected ("select all → refine"): misses (2) + the toggled row.
|
||||
const dense = scenario(38);
|
||||
|
||||
console.info(
|
||||
`fan-out of 1 toggle across 40 row effects — 10/40 selected: copy ${sparse.copy} vs in-place ${sparse.inplace}; 38/40 selected: copy ${dense.copy} vs in-place ${dense.inplace}`
|
||||
);
|
||||
// Copy-reassign invalidates every row that reads `.has` on the state.
|
||||
expect(sparse.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
expect(dense.copy).toBeGreaterThanOrEqual(rows.length);
|
||||
// In place: absent-key readers track the version signal (SvelteSet
|
||||
// design), present-key readers other than the toggled row are spared.
|
||||
expect(sparse.inplace).toBe(40 - 10 + 1);
|
||||
expect(dense.inplace).toBe(40 - 38 + 1);
|
||||
// The refine-after-select-all case is where the win is decisive.
|
||||
expect(dense.inplace).toBeLessThan(dense.copy / 10);
|
||||
});
|
||||
|
||||
it('1 000 toggles over a 5 000-id selection are ≥5x faster in place (perf gate)', () => {
|
||||
const N = 5_000;
|
||||
const TOGGLES = 1_000;
|
||||
const universe = ids(N);
|
||||
|
||||
const run = (model: SelectionModel): number => {
|
||||
model.seed(universe);
|
||||
const rand = mulberry32(0xbeef);
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < TOGGLES; i++) {
|
||||
model.toggle(universe[Math.floor(rand() * N)]);
|
||||
}
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(copyReassignModel());
|
||||
run(inPlaceModel());
|
||||
const copyMs = run(copyReassignModel());
|
||||
const inplaceMs = run(inPlaceModel());
|
||||
|
||||
console.info(
|
||||
`${TOGGLES} toggles @ N=${N}: copy-reassign ${copyMs.toFixed(1)} ms vs in-place ${inplaceMs.toFixed(1)} ms (${(copyMs / inplaceMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(inplaceMs).toBeLessThan(copyMs / 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNestedValue, interpolate } from './index.svelte';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the `t()` hot path: the split-path cache in
|
||||
* `getNestedValue` and the `{{` guard in `interpolate`.
|
||||
*
|
||||
* Audit finding: the locale dicts are nested, so every `t('a.b.c')` call
|
||||
* re-split its key into a fresh array and walked the tree, and `interpolate`
|
||||
* ran its global-regex `.replace` scan even though the vast majority of UI
|
||||
* strings carry no `{{placeholder}}`. A rendered list row calls `t()` ~10×,
|
||||
* so a 40-row paint pays ~400 walk+split-allocs + regex scans. The fix
|
||||
* caches the resolved value per (dict, key) — dicts are load-once-immutable
|
||||
* and the key set is the app's finite static strings — and skips the regex
|
||||
* when the string has no `{{`.
|
||||
*
|
||||
* Gates: byte-identical results vs the pre-fix reference implementations
|
||||
* across the real shipped en.json (nested keys, flat keys, underscore
|
||||
* fallback, missing keys, placeholder strings — cold AND warm, so a stale or
|
||||
* poisoned cache entry fails loudly), and a ≥1.5x speedup on a mixed
|
||||
* 20k-call workload.
|
||||
*/
|
||||
|
||||
type Dict = { [key: string]: string | Dict };
|
||||
|
||||
const enDict = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../../static/locales/en.json'), 'utf8')
|
||||
) as Dict;
|
||||
|
||||
/** Pre-fix `getNestedValue`, verbatim: fresh `split('.')` on every call. */
|
||||
function referenceGetNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in (current as Dict)) {
|
||||
current = (current as Dict)[key];
|
||||
} else {
|
||||
if (path.includes('_') && !path.includes('.')) {
|
||||
const [prefix, ...parts] = path.split('_');
|
||||
const suffix = parts.join('_');
|
||||
const branch = obj?.[prefix];
|
||||
if (branch && typeof branch === 'object' && suffix in (branch as Dict)) {
|
||||
const v = (branch as Dict)[suffix];
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/** Pre-fix `interpolate`, verbatim: unconditional regex `.replace`. */
|
||||
function referenceInterpolate(text: string, params: Record<string, unknown>): string {
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every dotted leaf path in the dict (the app's real key population). */
|
||||
function collectKeys(obj: Dict, prefix = '', out: string[] = []): string[] {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === 'string') out.push(path);
|
||||
else collectKeys(v, path, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allKeys = collectKeys(enDict);
|
||||
// A workload mix mirroring real renders: mostly present nested keys, plus
|
||||
// underscore-fallback forms, flat keys, and misses.
|
||||
const workload: string[] = [
|
||||
...allKeys,
|
||||
'errors_loadFailed', // underscore fallback form
|
||||
'groupby_modifiedAt',
|
||||
'nav.files',
|
||||
'this.key.does.not.exist',
|
||||
'nokey',
|
||||
'files.deeply.missing.leaf'
|
||||
];
|
||||
|
||||
const PARAMS = { n: 42, count: 7, email: 'x@y.z', name: 'Ada' };
|
||||
|
||||
describe('t() hot path: split cache + interpolate guard (benchmark gate)', () => {
|
||||
it('getNestedValue is byte-identical to the split-per-call reference on every real key', () => {
|
||||
expect(allKeys.length).toBeGreaterThan(300);
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), key).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
// Repeat with the cache warm — a poisoned/shared split array would show here.
|
||||
for (const key of workload) {
|
||||
expect(getNestedValue(enDict, key), `warm:${key}`).toBe(referenceGetNestedValue(enDict, key));
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolate is byte-identical to the unguarded reference', () => {
|
||||
const texts = [
|
||||
// Keys whose segments contain literal dots aren't resolvable via a
|
||||
// dotted path — drop the nulls (both implementations agree on them,
|
||||
// covered by the lookup-equivalence test above).
|
||||
...allKeys
|
||||
.map((k) => referenceGetNestedValue(enDict, k))
|
||||
.filter((v): v is string => v !== null),
|
||||
'Move {{n}} items to trash?',
|
||||
'{{ n }} spaced', // padded placeholder
|
||||
'{{unknown}} stays intact',
|
||||
'no placeholders at all',
|
||||
'brace but not double { x }',
|
||||
'{{n}}{{count}}back-to-back',
|
||||
''
|
||||
];
|
||||
let withPlaceholders = 0;
|
||||
for (const text of texts) {
|
||||
if (text.includes('{{')) withPlaceholders++;
|
||||
expect(interpolate(text, PARAMS), JSON.stringify(text)).toBe(
|
||||
referenceInterpolate(text, PARAMS)
|
||||
);
|
||||
expect(interpolate(text, {}), `noparams:${JSON.stringify(text)}`).toBe(
|
||||
referenceInterpolate(text, {})
|
||||
);
|
||||
}
|
||||
// The workload genuinely exercises both branches of the guard.
|
||||
expect(withPlaceholders).toBeGreaterThan(50);
|
||||
expect(withPlaceholders).toBeLessThan(texts.length / 2);
|
||||
});
|
||||
|
||||
it('20k mixed lookups+interpolations run ≥1.5x faster (perf gate)', { timeout: 30_000 }, () => {
|
||||
const N = 20_000;
|
||||
// The t() body for a hit: nested lookup then interpolate the result.
|
||||
const after = (key: string): string => {
|
||||
const v = getNestedValue(enDict, key);
|
||||
return v === null ? key : interpolate(v, PARAMS);
|
||||
};
|
||||
const before = (key: string): string => {
|
||||
const v = referenceGetNestedValue(enDict, key);
|
||||
return v === null ? key : referenceInterpolate(v, PARAMS);
|
||||
};
|
||||
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
sink += after(workload[i % workload.length]).length;
|
||||
sink += before(workload[i % workload.length]).length;
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += after(workload[i % workload.length]).length;
|
||||
const afterMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < N; i++) sink += before(workload[i % workload.length]).length;
|
||||
const beforeMs = performance.now() - t1;
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`t() hot path x ${N}: cached+guarded ${afterMs.toFixed(1)} ms vs split+regex-per-call ${beforeMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 1.5);
|
||||
});
|
||||
});
|
||||
@@ -116,8 +116,33 @@ export function resolveBrowserLocale(
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Resolved-value cache, one map per dict object: `t()` runs ~10× per rendered
|
||||
// list row over the app's finite static key set, so the nested split + tree
|
||||
// walk runs once per (locale, key) instead of on every call. Dicts are
|
||||
// assigned once in `loadDict` and never mutated, so entries can't go stale;
|
||||
// the cap only guards against a pathological dynamic-key caller.
|
||||
const RESOLVED_CACHE_MAX = 4000;
|
||||
const resolvedCache = new WeakMap<Dict, Map<string, string | null>>();
|
||||
|
||||
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
|
||||
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (!obj || typeof obj !== 'object') return resolveNestedValue(obj, path);
|
||||
let cache = resolvedCache.get(obj);
|
||||
if (cache === undefined) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- deliberately non-reactive: a memo written during render must not create/notify signals
|
||||
cache = new Map();
|
||||
resolvedCache.set(obj, cache);
|
||||
}
|
||||
const hit = cache.get(path);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = resolveNestedValue(obj, path);
|
||||
if (cache.size >= RESOLVED_CACHE_MAX) cache.clear();
|
||||
cache.set(path, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The uncached lookup: flat-key fast path, dotted walk, underscore fallback. */
|
||||
function resolveNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
@@ -146,6 +171,9 @@ export function getNestedValue(obj: Dict | undefined, path: string): string | nu
|
||||
|
||||
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
|
||||
export function interpolate(text: string, params: Record<string, unknown>): string {
|
||||
// The vast majority of UI strings carry no placeholder — skip the regex
|
||||
// scan (and its per-call machinery) for them.
|
||||
if (!text.includes('{{')) return text;
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Replace a live `Set`'s contents in place. For a reactive `SvelteSet` this
|
||||
* keeps the same instance (per-key reactivity intact) instead of allocating a
|
||||
* fresh copy and invalidating every `.has()` reader at once.
|
||||
*/
|
||||
export function replaceSet<T>(set: Set<T>, values: Iterable<T>): void {
|
||||
set.clear();
|
||||
for (const v of values) set.add(v);
|
||||
}
|
||||
@@ -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