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;
|
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
|
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||||
@@ -112,12 +123,15 @@ export async function fetchFolderListing(
|
|||||||
etag?: string;
|
etag?: string;
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
/**
|
/**
|
||||||
* Progressive render hook: invoked after EVERY page with the
|
* Progressive render hook: invoked with the accumulated listing so
|
||||||
* accumulated listing so far (the arrays are fresh copies — safe to
|
* far (the arrays are fresh copies — safe to hand to reactive
|
||||||
* hand to reactive state). Without it, a 2,000-item folder waited
|
* state). Without it, a 2,000-item folder waited for all ⌈N/200⌉
|
||||||
* for all ⌈N/200⌉ sequential round-trips before the first row
|
* sequential round-trips before the first row painted; with it the
|
||||||
* painted; with it the view paints after page one (~200 items) and
|
* view paints after page one (~200 items) and fills in as the tail
|
||||||
* fills in as the tail pages land.
|
* 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;
|
onPage?: (partial: FolderListing, done: boolean) => void;
|
||||||
} = {}
|
} = {}
|
||||||
@@ -125,6 +139,8 @@ export async function fetchFolderListing(
|
|||||||
const folders: FolderItem[] = [];
|
const folders: FolderItem[] = [];
|
||||||
const files: FileItem[] = [];
|
const files: FileItem[] = [];
|
||||||
let cursor: string | undefined;
|
let cursor: string | undefined;
|
||||||
|
let firstPage = true;
|
||||||
|
let lastEmit = 0;
|
||||||
do {
|
do {
|
||||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||||
@@ -144,10 +160,18 @@ export async function fetchFolderListing(
|
|||||||
else files.push(it.resource as FileItem);
|
else files.push(it.resource as FileItem);
|
||||||
}
|
}
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
opts.onPage?.(
|
const done = !cursor;
|
||||||
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
|
if (
|
||||||
!cursor
|
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);
|
} while (cursor);
|
||||||
|
|
||||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
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';
|
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. */
|
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
|
||||||
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
|
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) {
|
if (obj && typeof obj === 'object' && path in obj) {
|
||||||
const value = obj[path];
|
const value = obj[path];
|
||||||
return typeof value === 'string' ? value : null;
|
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. */
|
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
|
||||||
export function interpolate(text: string, params: Record<string, unknown>): string {
|
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) => {
|
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||||
const k = key.trim();
|
const k = key.trim();
|
||||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
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
|
typeLabel
|
||||||
} from '$lib/stores/files.svelte';
|
} from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
|
import { replaceSet } from '$lib/utils/sets';
|
||||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||||
import { gridColumns } from '$lib/utils/grid';
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
import {
|
import {
|
||||||
@@ -166,8 +167,11 @@
|
|||||||
// Favorite + shared badge sets for the current folder, seeded directly from
|
// Favorite + shared badge sets for the current folder, seeded directly from
|
||||||
// the listing response (server-computed, scoped to these items — no extra
|
// the listing response (server-computed, scoped to these items — no extra
|
||||||
// per-navigation fetch) and updated optimistically on mutation.
|
// per-navigation fetch) and updated optimistically on mutation.
|
||||||
let favoriteIds = $state<Set<string>>(new Set());
|
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
|
||||||
let sharedIds = $state<Set<string>>(new Set());
|
// 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) {
|
function openMove(kind: ItemType, id: string, name: string) {
|
||||||
actionTarget = { id, name, kind };
|
actionTarget = { id, name, kind };
|
||||||
@@ -189,19 +193,15 @@
|
|||||||
async function toggleFavorite(kind: ItemType, id: string) {
|
async function toggleFavorite(kind: ItemType, id: string) {
|
||||||
const isFav = favoriteIds.has(id);
|
const isFav = favoriteIds.has(id);
|
||||||
// Optimistic toggle, reverted on failure.
|
// Optimistic toggle, reverted on failure.
|
||||||
const next = new SvelteSet(favoriteIds);
|
if (isFav) favoriteIds.delete(id);
|
||||||
if (isFav) next.delete(id);
|
else favoriteIds.add(id);
|
||||||
else next.add(id);
|
|
||||||
favoriteIds = next;
|
|
||||||
try {
|
try {
|
||||||
if (isFav) await removeFavorite(kind, id);
|
if (isFav) await removeFavorite(kind, id);
|
||||||
else await addFavorite(kind, id);
|
else await addFavorite(kind, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
const reverted = new SvelteSet(favoriteIds);
|
if (isFav) favoriteIds.add(id);
|
||||||
if (isFav) reverted.add(id);
|
else favoriteIds.delete(id);
|
||||||
else reverted.delete(id);
|
|
||||||
favoriteIds = reverted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +229,8 @@
|
|||||||
|
|
||||||
function applyListing(data: FolderListing) {
|
function applyListing(data: FolderListing) {
|
||||||
listing = data;
|
listing = data;
|
||||||
favoriteIds = new Set(data.favoriteIds);
|
replaceSet(favoriteIds, data.favoriteIds);
|
||||||
sharedIds = new Set(data.sharedIds);
|
replaceSet(sharedIds, data.sharedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -881,19 +881,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-select + batch ────────────────────────────────────────────────
|
// ── 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.
|
// Anchor row id for shift-click range selection.
|
||||||
let selectionAnchor = $state<string | null>(null);
|
let selectionAnchor = $state<string | null>(null);
|
||||||
|
|
||||||
function toggleSelected(id: string) {
|
function toggleSelected(id: string) {
|
||||||
const next = new SvelteSet(selected);
|
if (selected.has(id)) selected.delete(id);
|
||||||
if (next.has(id)) next.delete(id);
|
else selected.add(id);
|
||||||
else next.add(id);
|
|
||||||
selected = next;
|
|
||||||
selectionAnchor = id;
|
selectionAnchor = id;
|
||||||
}
|
}
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selected = new Set();
|
selected.clear();
|
||||||
selectionAnchor = null;
|
selectionAnchor = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,7 +912,7 @@
|
|||||||
const b = orderedIds.indexOf(id);
|
const b = orderedIds.indexOf(id);
|
||||||
if (a !== -1 && b !== -1) {
|
if (a !== -1 && b !== -1) {
|
||||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -927,11 +928,16 @@
|
|||||||
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
|
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
if (selected.size === totalCount) clearSelection();
|
if (selected.size === totalCount) {
|
||||||
// Select-all only picks what the user can see — dotfiles hidden
|
clearSelection();
|
||||||
// by the current filter are excluded so "select all → delete"
|
} else {
|
||||||
// can't accidentally sweep up hidden files the user never saw.
|
// Select-all only picks what the user can see — dotfiles hidden
|
||||||
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
|
// 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() {
|
async function batchDownload() {
|
||||||
const fileIds: string[] = [];
|
const fileIds: string[] = [];
|
||||||
const folderIds: 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) {
|
for (const id of selected) {
|
||||||
if (listing.folders.some((f) => f.id === id)) folderIds.push(id);
|
if (folderIdSet.has(id)) folderIds.push(id);
|
||||||
else if (listing.files.some((f) => f.id === id)) fileIds.push(id);
|
else if (fileIdSet.has(id)) fileIds.push(id);
|
||||||
}
|
}
|
||||||
if (fileIds.length === 0 && folderIds.length === 0) return;
|
if (fileIds.length === 0 && folderIds.length === 0) return;
|
||||||
|
|
||||||
@@ -1009,7 +1018,7 @@
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
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');
|
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1018,13 +1027,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectionTargets(): ActionTarget[] {
|
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]
|
return [...selected]
|
||||||
.map((id) => {
|
.map((id) => byId.get(id) ?? null)
|
||||||
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;
|
|
||||||
})
|
|
||||||
.filter((x): x is ActionTarget => x !== null);
|
.filter((x): x is ActionTarget => x !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,15 +1080,19 @@
|
|||||||
danger: true
|
danger: true
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
for (const id of ids) {
|
// Bounded fan-out instead of a serial await per item: 100 deletes at
|
||||||
const folder = listing.folders.find((f) => f.id === id);
|
// ~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 {
|
try {
|
||||||
if (folder) await deleteFolder(id);
|
if (folderIdSet.has(id)) await deleteFolder(id);
|
||||||
else await deleteFile(id);
|
else await deleteFile(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await reload();
|
await reload();
|
||||||
void session.refresh();
|
void session.refresh();
|
||||||
@@ -1185,16 +1199,26 @@
|
|||||||
async function moveInto(targetFolderId: string, e: DragEvent) {
|
async function moveInto(targetFolderId: string, e: DragEvent) {
|
||||||
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
try {
|
// Bounded fan-out (was a serial await per item). Every item is
|
||||||
for (const it of items) {
|
// attempted; on any failure the first error is surfaced and the
|
||||||
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
// selection is kept so the drop can be retried, like the old loop.
|
||||||
else await moveFolder(it.id, targetFolderId);
|
const failures = (
|
||||||
}
|
await mapLimit(items, 6, async (it) => {
|
||||||
clearSelection();
|
try {
|
||||||
await reload();
|
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
|
||||||
} catch (err) {
|
else await moveFolder(it.id, targetFolderId);
|
||||||
errorToast(err);
|
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) {
|
function onFolderDrop(e: DragEvent, folder: FolderItem) {
|
||||||
@@ -2244,11 +2268,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if shareDialog.component}
|
{#if shareDialog.component}
|
||||||
{@const ShareDialog = shareDialog.component}
|
{@const ShareDialog = shareDialog.component}
|
||||||
<ShareDialog
|
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
|
||||||
bind:open={shareOpen}
|
|
||||||
item={actionTarget}
|
|
||||||
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if fileViewer.component}
|
{#if fileViewer.component}
|
||||||
{@const FileViewer = 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 { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { preferences } from '$lib/stores/preferences.svelte';
|
||||||
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
import { filterDotfiles } from '$lib/utils/dotfileFilter';
|
||||||
|
import { replaceSet } from '$lib/utils/sets';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
let raw = $state<RecentResourceItem[]>([]);
|
let raw = $state<RecentResourceItem[]>([]);
|
||||||
@@ -37,7 +38,9 @@
|
|||||||
let groupBy = $state('');
|
let groupBy = $state('');
|
||||||
let reversed = $state(false);
|
let reversed = $state(false);
|
||||||
const owners = useOwnerCache(resolveOwnerName);
|
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])));
|
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||||
|
|
||||||
@@ -109,7 +112,10 @@
|
|||||||
async function loadFavoriteIds() {
|
async function loadFavoriteIds() {
|
||||||
try {
|
try {
|
||||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
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 {
|
} catch {
|
||||||
// non-fatal — stars just default to off
|
// non-fatal — stars just default to off
|
||||||
}
|
}
|
||||||
@@ -169,18 +175,15 @@
|
|||||||
|
|
||||||
async function toggleFavorite(entry: ResourceEntry) {
|
async function toggleFavorite(entry: ResourceEntry) {
|
||||||
const isFav = favoriteIds.has(entry.id);
|
const isFav = favoriteIds.has(entry.id);
|
||||||
const next = new SvelteSet(favoriteIds);
|
// Optimistic in-place toggle, reverted on failure.
|
||||||
if (isFav) next.delete(entry.id);
|
if (isFav) favoriteIds.delete(entry.id);
|
||||||
else next.add(entry.id);
|
else favoriteIds.add(entry.id);
|
||||||
favoriteIds = next;
|
|
||||||
try {
|
try {
|
||||||
if (isFav) await removeFavorite(entry.kind, entry.id);
|
if (isFav) await removeFavorite(entry.kind, entry.id);
|
||||||
else await addFavorite(entry.kind, entry.id);
|
else await addFavorite(entry.kind, entry.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// revert on failure
|
if (isFav) favoriteIds.add(entry.id);
|
||||||
favoriteIds = isFav
|
else favoriteIds.delete(entry.id);
|
||||||
? new Set([...favoriteIds, entry.id])
|
|
||||||
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user