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:
Claude
2026-07-18 00:54:38 +00:00
parent 63cf6646d0
commit 61c9470981
10 changed files with 905 additions and 71 deletions
@@ -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);
}
);
});
+34 -10
View File
@@ -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);
});
});
+167
View File
@@ -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);
});
});
+28
View File
@@ -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}}}`;
+9
View File
@@ -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);
});
});
+13 -10
View File
@@ -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);
}
}